diff --git a/.all-contributorsrc b/.all-contributorsrc index a230a4685..3cf5e765c 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -665,6 +665,78 @@ "contributions": [ "translation" ] + }, + { + "login": "sambartik", + "name": "Samuel Bartík", + "avatar_url": "https://avatars.githubusercontent.com/u/63553146?v=4", + "profile": "https://github.com/sambartik", + "contributions": [ + "code" + ] + }, + { + "login": "frank-cywong", + "name": "Chun Yeung Wong", + "avatar_url": "https://avatars.githubusercontent.com/u/90653148?v=4", + "profile": "https://github.com/frank-cywong", + "contributions": [ + "code" + ] + }, + { + "login": "TheMeanCanEHdian", + "name": "TheMeanCanEHdian", + "avatar_url": "https://avatars.githubusercontent.com/u/16025103?v=4", + "profile": "https://github.com/TheMeanCanEHdian", + "contributions": [ + "code" + ] + }, + { + "login": "Gylesie", + "name": "Gylesie", + "avatar_url": "https://avatars.githubusercontent.com/u/86306812?v=4", + "profile": "https://github.com/Gylesie", + "contributions": [ + "code" + ] + }, + { + "login": "Fhd-pro", + "name": "Fhd-pro", + "avatar_url": "https://avatars.githubusercontent.com/u/82862079?v=4", + "profile": "https://github.com/Fhd-pro", + "contributions": [ + "translation" + ] + }, + { + "login": "PovilasID", + "name": "PovilasID", + "avatar_url": "https://avatars.githubusercontent.com/u/396243?v=4", + "profile": "https://github.com/PovilasID", + "contributions": [ + "translation" + ] + }, + { + "login": "byakurau", + "name": "byakurau", + "avatar_url": "https://avatars.githubusercontent.com/u/1811683?v=4", + "profile": "https://github.com/byakurau", + "contributions": [ + "translation" + ] + }, + { + "login": "miknii", + "name": "miknii", + "avatar_url": "https://avatars.githubusercontent.com/u/109232569?v=4", + "profile": "https://github.com/miknii", + "contributions": [ + "translation" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", @@ -673,5 +745,5 @@ "projectOwner": "sct", "repoType": "github", "repoHost": "https://github.com", - "skipCi": true + "skipCi": false } diff --git a/.dockerignore b/.dockerignore index 7d669c86d..21a5da869 100644 --- a/.dockerignore +++ b/.dockerignore @@ -26,3 +26,4 @@ public/os_logo_filled.png public/preview.jpg snap stylelint.config.js +cypress diff --git a/.eslintrc.js b/.eslintrc.js index b1c6f4b9f..5af484c53 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -7,6 +7,7 @@ module.exports = { 'plugin:jsx-a11y/recommended', 'plugin:react/recommended', 'plugin:react-hooks/recommended', + 'plugin:react/jsx-runtime', 'prettier', ], parserOptions: { @@ -26,11 +27,21 @@ module.exports = { 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'warn', '@typescript-eslint/explicit-function-return-type': 'off', - 'prettier/prettier': ['error', { endOfLine: 'auto' }], 'formatjs/no-offset': 'error', 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': ['error'], + '@typescript-eslint/array-type': ['error', { default: 'array' }], 'jsx-a11y/no-onchange': 'off', + '@typescript-eslint/consistent-type-imports': [ + 'error', + { + prefer: 'type-imports', + }, + ], + 'no-relative-import-paths/no-relative-import-paths': [ + 'error', + { allowSameFolder: true }, + ], }, 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: { react: { pragma: 'React', diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44215bded..d2026ee71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: Jellyseerr CI on: pull_request: branches: - - "*" + - '*' push: branches: - develop @@ -13,16 +13,18 @@ jobs: name: Lint & Test Build if: github.event_name == 'pull_request' runs-on: ubuntu-20.04 - container: node:16.14-alpine + container: node:16.17-alpine steps: - name: Checkout uses: actions/checkout@v3 - name: Install dependencies env: - HUSKY_SKIP_INSTALL: 1 + HUSKY: 0 run: yarn - name: Lint run: yarn lint + - name: Formatting + run: yarn format:check - name: Build run: yarn build @@ -34,23 +36,29 @@ jobs: - name: Checkout uses: actions/checkout@v3 - name: Set up QEMU - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v2 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v2 - name: Cache Docker layers - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ github.sha }} restore-keys: | ${{ runner.os }}-buildx- - name: Log in to Docker Hub - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} + - name: Log in to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v3 with: context: . file: ./Dockerfile @@ -77,7 +85,7 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Get Build Job Status - uses: technote-space/workflow-conclusion-action@v2 + uses: technote-space/workflow-conclusion-action@v3 - name: Combine Job Status id: status run: | diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml new file mode 100644 index 000000000..ecd260dd5 --- /dev/null +++ b/.github/workflows/cypress.yml @@ -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}} diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 5d104c7c8..35ae768b7 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -3,7 +3,7 @@ name: Jellyseerr Preview on: push: tags: - - "preview-*" + - 'preview-*' jobs: build_and_push: @@ -16,16 +16,16 @@ jobs: id: get_version run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/} - name: Set up QEMU - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v2 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v2 - name: Log in to Docker Hub - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} - name: Build and push - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v3 with: context: . file: ./Dockerfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 66a1ac47f..8890dcae3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ on: workflow_dispatch jobs: semantic-release: name: Tag and release latest version - runs-on: self-hosted + runs-on: ubuntu-20.04 env: HUSKY: 0 steps: @@ -18,16 +18,14 @@ jobs: with: node-version: 16 - name: Set up QEMU - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v2 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v2 - name: Log in to Docker Hub - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} - - name: Install Yarn - run: npm install -g yarn - name: Install dependencies run: yarn - name: Release @@ -37,6 +35,60 @@ jobs: DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} run: npx semantic-release + build-snap: + name: Build Snap Package (${{ matrix.architecture }}) + needs: semantic-release + runs-on: ubuntu-20.04 + strategy: + fail-fast: false + matrix: + architecture: + - amd64 + - arm64 + - armhf + steps: + - name: Checkout Code + uses: actions/checkout@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: name: Send Discord Notification needs: semantic-release @@ -44,7 +96,7 @@ jobs: runs-on: self-hosted steps: - name: Get Build Job Status - uses: technote-space/workflow-conclusion-action@v2 + uses: technote-space/workflow-conclusion-action@v3 - name: Combine Job Status id: status run: | diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml new file mode 100644 index 000000000..bf00e04d7 --- /dev/null +++ b/.github/workflows/snap.yaml @@ -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 diff --git a/.gitignore b/.gitignore index 41a0481fd..70a5d6f2f 100644 --- a/.gitignore +++ b/.gitignore @@ -54,5 +54,16 @@ config/db/db.sqlite3-journal # VS Code .vscode/launch.json +# Cypress +cypress.env.json +cypress/videos +cypress/screenshots + +# ESLint +.eslintcache + +# TS Build Info +tsconfig.tsbuildinfo + # Webstorm .idea diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 000000000..c735fffac --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,5 @@ +module.exports = { + plugins: [require('./merged-prettier-plugin.js')], + singleQuote: true, + trailingComma: 'es5', +}; diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 80a16c644..8dc1918fb 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -11,9 +11,6 @@ // https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode "esbenp.prettier-vscode", - // https://marketplace.visualstudio.com/items?itemName=eg2.vscode-npm-script - "eg2.vscode-npm-script", - // https://marketplace.visualstudio.com/items?itemName=Orta.vscode-jest "Orta.vscode-jest", diff --git a/.vscode/settings.json b/.vscode/settings.json index 26aca34b8..45da7ba67 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,8 +15,6 @@ "database": "./config/db/db.sqlite3" } ], - "editor.codeActionsOnSave": { - "source.organizeImports": true - }, - "editor.formatOnSave": true + "editor.formatOnSave": true, + "typescript.preferences.importModuleSpecifier": "non-relative" } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a016f6d46..96e67c8ac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,7 +86,7 @@ When adding new UI text, please try to adhere to the following guidelines: 1. Be concise and clear, and use as few words as possible to make your point. 2. Use the Oxford comma where appropriate. 3. Use the appropriate Unicode characters for ellipses, arrows, and other special characters/symbols. -4. Capitalize proper nouns, such as Plex, Radarr, Sonarr, Telegram, Slack, Pushover, etc. Be sure to also use the official capitalization for any abbreviations; e.g., TMDb and IMDb have a lowercase 'b', whereas TheTVDB has a capital 'B'. +4. Capitalize proper nouns, such as Plex, Radarr, Sonarr, Telegram, Slack, Pushover, etc. Be sure to also use the official capitalization for any abbreviations; e.g., IMDb has a lowercase 'b', whereas TMDB and TheTVDB have a capital 'B'. 5. Title case headings, button text, and form labels. Note that verbs such as "is" should be capitalized, whereas prepositions like "from" should be lowercase (unless as the first or last word of the string, in which case they are also capitalized). 6. Capitalize the first word in validation error messages, dropdowns, and form "tips." These strings should not end in punctuation. 7. Ensure that toast notification strings are complete sentences ending in punctuation. diff --git a/Dockerfile b/Dockerfile index 8f3ed32c8..851ba4721 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:16.14-alpine AS BUILD_IMAGE +FROM node:16.17-alpine AS BUILD_IMAGE WORKDIR /app @@ -14,7 +14,7 @@ RUN \ esac COPY package.json yarn.lock ./ -RUN yarn install --frozen-lockfile --network-timeout 1000000 +RUN CYPRESS_INSTALL_BINARY=0 yarn install --frozen-lockfile --network-timeout 1000000 COPY . ./ @@ -33,7 +33,7 @@ RUN touch config/DOCKER RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json -FROM node:16.14-alpine +FROM node:16.17-alpine WORKDIR /app diff --git a/Dockerfile.local b/Dockerfile.local index f0228b6b9..39e0534f3 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -1,4 +1,4 @@ -FROM node:16.14-alpine +FROM node:16.17-alpine COPY . /app WORKDIR /app diff --git a/cypress.config.ts b/cypress.config.ts new file mode 100644 index 000000000..07b0c8b1d --- /dev/null +++ b/cypress.config.ts @@ -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, + }, +}); diff --git a/cypress/config/settings.cypress.json b/cypress/config/settings.cypress.json new file mode 100644 index 000000000..bb7b661b0 --- /dev/null +++ b/cypress/config/settings.cypress.json @@ -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 * * *" + } + } + } diff --git a/cypress/e2e/discover.cy.ts b/cypress/e2e/discover.cy.ts new file mode 100644 index 000000000..3489061b0 --- /dev/null +++ b/cypress/e2e/discover.cy.ts @@ -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); + }); + }); +}); diff --git a/cypress/e2e/login.cy.ts b/cypress/e2e/login.cy.ts new file mode 100644 index 000000000..1c9554174 --- /dev/null +++ b/cypress/e2e/login.cy.ts @@ -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'); + }); +}); diff --git a/cypress/e2e/movie-details.cy.ts b/cypress/e2e/movie-details.cy.ts new file mode 100644 index 000000000..1d3ecf3f1 --- /dev/null +++ b/cypress/e2e/movie-details.cy.ts @@ -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)' + ); + }); +}); diff --git a/cypress/e2e/settings/general-settings.cy.ts b/cypress/e2e/settings/general-settings.cy.ts new file mode 100644 index 000000000..3717f65b0 --- /dev/null +++ b/cypress/e2e/settings/general-settings.cy.ts @@ -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'); + }); +}); diff --git a/cypress/e2e/tv-details.cy.ts b/cypress/e2e/tv-details.cy.ts new file mode 100644 index 000000000..5b4bd049a --- /dev/null +++ b/cypress/e2e/tv-details.cy.ts @@ -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'); + }); +}); diff --git a/cypress/e2e/user/auto-request-settings.cy.ts b/cypress/e2e/user/auto-request-settings.cy.ts new file mode 100644 index 000000000..e7f5727be --- /dev/null +++ b/cypress/e2e/user/auto-request-settings.cy.ts @@ -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(); + }); +}); diff --git a/cypress/e2e/user/profile.cy.ts b/cypress/e2e/user/profile.cy.ts new file mode 100644 index 000000000..9cc38d887 --- /dev/null +++ b/cypress/e2e/user/profile.cy.ts @@ -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); + }); + }); +}); diff --git a/cypress/e2e/user/user-list.cy.ts b/cypress/e2e/user/user-list.cy.ts new file mode 100644 index 000000000..503bd23f1 --- /dev/null +++ b/cypress/e2e/user/user-list.cy.ts @@ -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'); + }); +}); diff --git a/cypress/fixtures/watchlist.json b/cypress/fixtures/watchlist.json new file mode 100644 index 000000000..896cef740 --- /dev/null +++ b/cypress/fixtures/watchlist.json @@ -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 + } + ] +} diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts new file mode 100644 index 000000000..e1afafe7c --- /dev/null +++ b/cypress/support/commands.ts @@ -0,0 +1,34 @@ +/// + +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')); +}); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts new file mode 100644 index 000000000..7a7697cab --- /dev/null +++ b/cypress/support/e2e.ts @@ -0,0 +1,7 @@ +import './commands'; + +before(() => { + if (Cypress.env('SEED_DATABASE')) { + cy.exec('yarn cypress:prepare'); + } +}); diff --git a/cypress/support/index.ts b/cypress/support/index.ts new file mode 100644 index 000000000..857067613 --- /dev/null +++ b/cypress/support/index.ts @@ -0,0 +1,14 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +/// + +declare global { + namespace Cypress { + interface Chainable { + login(email?: string, password?: string): Chainable; + loginAsAdmin(): Chainable; + loginAsUser(): Chainable; + } + } +} + +export {}; diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json new file mode 100644 index 000000000..1b6425b80 --- /dev/null +++ b/cypress/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["es5", "dom"], + "types": ["cypress", "node"], + "resolveJsonModule": true, + "esModuleInterop": true + }, + "include": ["**/*.ts"] +} diff --git a/docs/extending-overseerr/third-party.md b/docs/extending-overseerr/third-party.md index 7ff2bcabf..e1bacfc67 100644 --- a/docs/extending-overseerr/third-party.md +++ b/docs/extending-overseerr/third-party.md @@ -9,7 +9,7 @@ - [LunaSea](https://docs.lunasea.app/modules/overseerr), a self-hosted controller for mobile and macOS - [Requestrr](https://github.com/darkalfx/requestrr/wiki/Configuring-Overseerr), a Discord chatbot - [Doplarr](https://github.com/kiranshila/Doplarr), a Discord request bot -- [Overseerr Assistant](https://github.com/RemiRigal/Overseerr-Assistant), a browser extension for requesting directly from TMDb and IMDb +- [Overseerr Assistant](https://github.com/RemiRigal/Overseerr-Assistant), a browser extension for requesting directly from TMDB and IMDb - [ha-overseerr](https://github.com/vaparr/ha-overseerr), a custom Home Assistant component - [OverCLIrr](https://github.com/WillFantom/OverCLIrr), a command-line tool - [Overseerr Exporter](https://github.com/WillFantom/overseerr-exporter), a Prometheus exporter diff --git a/docs/support/faq.md b/docs/support/faq.md index 56a170941..c638e8636 100644 --- a/docs/support/faq.md +++ b/docs/support/faq.md @@ -45,7 +45,7 @@ Overseerr currently supports the following agents: - New Plex TV - Legacy Plex TV - TheTVDB -- TMDb +- TMDB - [HAMA](https://github.com/ZeroQI/Hama.bundle) Please verify that your library is using one of the agents previously listed. @@ -67,7 +67,7 @@ You can also perform the following to verify the media item has a GUID Overseerr 1. Go to the media item in Plex and **"Get info"** and click on **"View XML"**. 2. Verify that the media item's GUID follows one of the below formats: - 1. TMDb agent `guid="com.plexapp.agents.themoviedb://1705"` + 1. TMDB agent `guid="com.plexapp.agents.themoviedb://1705"` 2. New Plex Movie agent `` 3. TheTVDB agent `guid="com.plexapp.agents.thetvdb://78874/1/1"` 4. Legacy Plex Movie agent `guid="com.plexapp.agents.imdb://tt0765446"` diff --git a/docs/using-overseerr/notifications/webhooks.md b/docs/using-overseerr/notifications/webhooks.md index 37a5c0486..cd2ada314 100644 --- a/docs/using-overseerr/notifications/webhooks.md +++ b/docs/using-overseerr/notifications/webhooks.md @@ -81,7 +81,7 @@ These following special variables are only included in media-related notificatio | Variable | Value | | -------------------- | -------------------------------------------------------------------------------------------------------------- | | `{{media_type}}` | The media type (`movie` or `tv`) | -| `{{media_tmdbid}}` | The media's TMDb ID | +| `{{media_tmdbid}}` | The media's TMDB ID | | `{{media_tvdbid}}` | The media's TheTVDB ID | | `{{media_status}}` | The media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) | | `{{media_status4k}}` | The media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) | diff --git a/merged-prettier-plugin.js b/merged-prettier-plugin.js new file mode 100644 index 000000000..6908488f5 --- /dev/null +++ b/merged-prettier-plugin.js @@ -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; diff --git a/next.config.js b/next.config.js index 40d899f7a..2e6800737 100644 --- a/next.config.js +++ b/next.config.js @@ -1,3 +1,6 @@ +/** + * @type {import('next').NextConfig} + */ module.exports = { env: { commitTag: process.env.COMMIT_TAG || 'local', diff --git a/overseerr-api.yml b/overseerr-api.yml index 551f7dd91..e69b42591 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1841,14 +1841,14 @@ components: paths: /status: get: - summary: Get Overseerr version - description: Returns the current Overseerr version in a JSON object. + summary: Get Overseerr status + description: Returns the current Overseerr status in a JSON object. security: [] tags: - public responses: '200': - description: Returned version + description: Returned status content: application/json: schema: @@ -1859,6 +1859,12 @@ paths: example: 1.0.0 commitTag: type: string + updateAvailable: + type: boolean + commitsBehind: + type: number + restartRequired: + type: boolean /status/appdata: get: summary: Get application data volume status @@ -3394,8 +3400,8 @@ paths: name: guid required: true schema: - type: number - example: 1 + type: string + example: '9afef5a7-ec89-4d5f-9397-261e96970b50' responses: '200': description: OK @@ -3759,6 +3765,53 @@ paths: restricted: type: boolean 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: get: summary: Get general settings for a user @@ -4650,6 +4703,46 @@ paths: name: type: string example: Genre Name + /discover/watchlist: + get: + summary: Get the Plex watchlist. + tags: + - search + parameters: + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + responses: + '200': + description: Watchlist data returned + content: + application/json: + schema: + type: object + properties: + page: + type: number + totalPages: + type: number + totalResults: + type: number + results: + type: array + items: + type: object + properties: + tmdbId: + type: number + example: 1 + ratingKey: + type: string + type: + type: string + title: + type: string /request: get: summary: Get all requests @@ -4677,7 +4770,16 @@ paths: schema: type: string nullable: true - enum: [all, approved, available, pending, processing, unavailable] + enum: + [ + all, + approved, + available, + pending, + processing, + unavailable, + failed, + ] - in: query name: sort schema: @@ -5580,7 +5682,7 @@ paths: $ref: '#/components/schemas/SonarrSeries' /regions: get: - summary: Regions supported by TMDb + summary: Regions supported by TMDB description: Returns a list of regions in a JSON object. tags: - tmdb @@ -5602,7 +5704,7 @@ paths: example: United States of America /languages: get: - summary: Languages supported by TMDb + summary: Languages supported by TMDB description: Returns a list of languages in a JSON object. tags: - tmdb @@ -5667,7 +5769,7 @@ paths: $ref: '#/components/schemas/ProductionCompany' /genres/movie: get: - summary: Get list of official TMDb movie genres + summary: Get list of official TMDB movie genres description: Returns a list of genres in a JSON array. tags: - tmdb @@ -5695,7 +5797,7 @@ paths: example: Family /genres/tv: get: - summary: Get list of official TMDb movie genres + summary: Get list of official TMDB movie genres description: Returns a list of genres in a JSON array. tags: - tmdb diff --git a/package.json b/package.json index 7d5bb637d..056206966 100644 --- a/package.json +++ b/package.json @@ -3,18 +3,25 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node --files --project server/tsconfig.json server/index.ts", - "build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates", + "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 && tsc-alias -p server/tsconfig.json", "build:next": "next build", "build": "yarn build:next && yarn build:server", - "lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\"", + "lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache", "start": "NODE_ENV=production node dist/index.js", "i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault true \"./src/**/!(*.test).{ts,tsx}\"", - "migration:generate": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate", - "migration:create": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create", - "migration:run": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run", - "format": "prettier --write .", - "prepare": "husky install" + "migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts", + "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 -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts", + "format": "prettier --loglevel warn --write --cache .", + "format:check": "prettier --check --cache .", + "typecheck": "yarn typecheck:server && yarn typecheck:client", + "typecheck:server": "tsc --project server/tsconfig.json --noEmit", + "typecheck:client": "tsc --noEmit", + "prepare": "husky install", + "cypress:open": "cypress open", + "cypress:prepare": "ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/scripts/prepareTestDb.ts", + "cypress:build": "yarn build && yarn cypress:prepare" }, "repository": { "type": "git", @@ -22,129 +29,141 @@ }, "license": "MIT", "dependencies": { - "@headlessui/react": "^1.5.0", - "@heroicons/react": "^1.0.6", - "@supercharge/request-ip": "^1.2.0", - "@svgr/webpack": "^6.2.1", - "@tanem/react-nprogress": "^4.0.10", - "ace-builds": "^1.4.14", - "axios": "^0.26.1", - "bcrypt": "^5.0.1", - "bowser": "^2.11.0", - "connect-typeorm": "^1.1.4", - "cookie-parser": "^1.4.6", - "copy-to-clipboard": "^3.3.1", - "country-flag-icons": "^1.4.21", - "csurf": "^1.11.0", - "email-templates": "^8.0.10", - "email-validator": "^2.0.4", - "express": "^4.17.3", - "express-openapi-validator": "^4.13.6", - "express-rate-limit": "^6.3.0", - "express-session": "^1.17.2", - "formik": "^2.2.9", - "gravatar-url": "^3.1.0", - "intl": "^1.2.5", - "lodash": "^4.17.21", - "next": "12.1.0", - "node-cache": "^5.1.2", - "node-gyp": "^9.0.0", - "node-schedule": "^2.1.0", - "nodemailer": "^6.7.2", - "openpgp": "^5.2.0", - "plex-api": "^5.3.2", - "pug": "^3.0.2", - "react": "17.0.2", - "react-ace": "^9.5.0", - "react-animate-height": "^2.0.23", - "react-dom": "17.0.2", - "react-intersection-observer": "^8.33.1", - "react-intl": "5.24.7", - "react-markdown": "^8.0.0", - "react-select": "^5.2.2", - "react-spring": "^9.4.4", - "react-toast-notifications": "^2.5.1", - "react-transition-group": "^4.4.2", - "react-truncate-markup": "^5.1.0", - "react-use-clipboard": "1.0.7", - "reflect-metadata": "^0.1.13", - "secure-random-password": "^0.2.3", - "semver": "^7.3.5", - "sqlite3": "^5.0.2", - "swagger-ui-express": "^4.3.0", - "swr": "^1.2.2", - "typeorm": "0.2.45", - "web-push": "^3.4.5", - "winston": "^3.6.0", - "winston-daily-rotate-file": "^4.6.1", - "xml2js": "^0.4.23", - "yamljs": "^0.3.0", - "yup": "^0.32.11" + "@formatjs/intl-displaynames": "6.0.3", + "@formatjs/intl-locale": "3.0.3", + "@formatjs/intl-pluralrules": "5.0.3", + "@formatjs/intl-utils": "3.8.4", + "@headlessui/react": "0.0.0-insiders.b301f04", + "@heroicons/react": "1.0.6", + "@supercharge/request-ip": "1.2.0", + "@svgr/webpack": "6.3.1", + "@tanem/react-nprogress": "5.0.11", + "ace-builds": "1.9.6", + "axios": "0.27.2", + "axios-rate-limit": "1.3.0", + "bcrypt": "5.0.1", + "bowser": "2.11.0", + "connect-typeorm": "1.1.4", + "cookie-parser": "1.4.6", + "copy-to-clipboard": "3.3.2", + "country-flag-icons": "1.5.5", + "csurf": "1.11.0", + "date-fns": "2.29.1", + "email-templates": "9.0.0", + "express": "4.18.1", + "express-openapi-validator": "4.13.8", + "express-rate-limit": "6.5.1", + "express-session": "1.17.3", + "formik": "2.2.9", + "gravatar-url": "3.1.0", + "intl": "1.2.5", + "lodash": "4.17.21", + "next": "12.2.5", + "node-cache": "5.1.2", + "node-gyp": "9.1.0", + "node-schedule": "2.1.0", + "nodemailer": "6.7.8", + "openpgp": "5.4.0", + "plex-api": "5.3.2", + "pug": "3.0.2", + "react": "18.2.0", + "react-ace": "10.1.0", + "react-animate-height": "2.1.2", + "react-dom": "18.2.0", + "react-intersection-observer": "9.4.0", + "react-intl": "6.0.5", + "react-markdown": "8.0.3", + "react-popper-tooltip": "4.4.2", + "react-select": "5.4.0", + "react-spring": "9.5.2", + "react-toast-notifications": "2.5.1", + "react-truncate-markup": "5.1.2", + "react-use-clipboard": "1.0.8", + "reflect-metadata": "0.1.13", + "secure-random-password": "0.2.3", + "semver": "7.3.7", + "sqlite3": "5.0.11", + "swagger-ui-express": "4.5.0", + "swr": "1.3.0", + "typeorm": "0.3.7", + "web-push": "3.5.0", + "winston": "3.8.1", + "winston-daily-rotate-file": "4.7.1", + "xml2js": "0.4.23", + "yamljs": "0.3.0", + "yup": "0.32.11" }, "devDependencies": { - "@babel/cli": "^7.17.6", - "@commitlint/cli": "^16.2.1", - "@commitlint/config-conventional": "^16.2.1", - "@next/eslint-plugin-next": "^12.1.6", - "@semantic-release/changelog": "^6.0.1", - "@semantic-release/commit-analyzer": "^9.0.2", - "@semantic-release/exec": "^6.0.3", - "@semantic-release/git": "^10.0.1", - "@tailwindcss/aspect-ratio": "^0.4.0", - "@tailwindcss/forms": "^0.5.0", - "@tailwindcss/typography": "^0.5.2", - "@types/bcrypt": "^5.0.0", - "@types/cookie-parser": "^1.4.2", - "@types/country-flag-icons": "^1.2.0", - "@types/csurf": "^1.11.2", - "@types/email-templates": "^8.0.4", - "@types/express": "^4.17.13", - "@types/express-session": "^1.17.4", - "@types/lodash": "^4.14.179", - "@types/node": "^17.0.21", - "@types/node-schedule": "^1.3.2", - "@types/nodemailer": "^6.4.4", - "@types/react": "^17.0.40", - "@types/react-dom": "^17.0.13", - "@types/react-transition-group": "^4.4.4", - "@types/secure-random-password": "^0.2.1", - "@types/semver": "^7.3.9", - "@types/swagger-ui-express": "^4.1.3", - "@types/web-push": "^3.3.2", - "@types/xml2js": "^0.4.9", - "@types/yamljs": "^0.2.31", - "@types/yup": "^0.29.13", - "@typescript-eslint/eslint-plugin": "^5.14.0", - "@typescript-eslint/parser": "^5.14.0", - "autoprefixer": "^10.4.2", - "babel-plugin-react-intl": "^8.2.25", - "babel-plugin-react-intl-auto": "^3.3.0", - "commitizen": "^4.2.4", - "copyfiles": "^2.4.1", - "cz-conventional-changelog": "^3.3.0", - "eslint": "^8.11.0", - "eslint-config-next": "^12.1.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-formatjs": "^3.0.0", - "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-prettier": "^4.0.0", - "eslint-plugin-react": "^7.29.3", - "eslint-plugin-react-hooks": "^4.3.0", - "extract-react-intl-messages": "^4.1.1", - "husky": "^7.0.4", - "lint-staged": "^12.3.5", - "nodemon": "^2.0.15", - "postcss": "^8.4.8", - "prettier": "^2.5.1", - "prettier-plugin-tailwindcss": "^0.1.8", - "semantic-release": "^19.0.2", - "semantic-release-docker-buildx": "^1.0.1", - "tailwindcss": "^3.0.23", - "ts-node": "^10.7.0", - "typescript": "^4.6.2" + "@babel/cli": "7.18.10", + "@commitlint/cli": "17.0.3", + "@commitlint/config-conventional": "17.0.3", + "@semantic-release/changelog": "6.0.1", + "@semantic-release/commit-analyzer": "9.0.2", + "@semantic-release/exec": "6.0.3", + "@semantic-release/git": "10.0.1", + "@tailwindcss/aspect-ratio": "0.4.0", + "@tailwindcss/forms": "0.5.2", + "@tailwindcss/typography": "0.5.4", + "@types/bcrypt": "5.0.0", + "@types/cookie-parser": "1.4.3", + "@types/country-flag-icons": "1.2.0", + "@types/csurf": "1.11.2", + "@types/email-templates": "8.0.4", + "@types/express": "4.17.13", + "@types/express-session": "1.17.4", + "@types/lodash": "4.14.183", + "@types/node": "17.0.36", + "@types/node-schedule": "2.1.0", + "@types/nodemailer": "6.4.5", + "@types/react": "18.0.17", + "@types/react-dom": "18.0.6", + "@types/react-transition-group": "4.4.5", + "@types/secure-random-password": "0.2.1", + "@types/semver": "7.3.12", + "@types/swagger-ui-express": "4.1.3", + "@types/web-push": "3.3.2", + "@types/xml2js": "0.4.11", + "@types/yamljs": "0.2.31", + "@types/yup": "0.29.14", + "@typescript-eslint/eslint-plugin": "5.33.1", + "@typescript-eslint/parser": "5.33.1", + "autoprefixer": "10.4.8", + "babel-plugin-react-intl": "8.2.25", + "babel-plugin-react-intl-auto": "3.3.0", + "commitizen": "4.2.5", + "copyfiles": "2.4.1", + "cypress": "10.6.0", + "cz-conventional-changelog": "3.3.0", + "email-validator": "2.0.4", + "eslint": "8.22.0", + "eslint-config-next": "12.2.5", + "eslint-config-prettier": "8.5.0", + "eslint-plugin-formatjs": "4.1.0", + "eslint-plugin-jsx-a11y": "6.6.1", + "eslint-plugin-no-relative-import-paths": "1.4.0", + "eslint-plugin-prettier": "4.2.1", + "eslint-plugin-react": "7.30.1", + "eslint-plugin-react-hooks": "4.6.0", + "extract-react-intl-messages": "4.1.1", + "husky": "8.0.1", + "lint-staged": "12.4.3", + "nodemon": "2.0.19", + "postcss": "8.4.16", + "prettier": "2.7.1", + "prettier-plugin-organize-imports": "3.1.0", + "prettier-plugin-tailwindcss": "0.1.13", + "semantic-release": "19.0.3", + "semantic-release-docker-buildx": "1.0.1", + "tailwindcss": "3.1.8", + "ts-node": "10.9.1", + "tsc-alias": "1.7.0", + "tsconfig-paths": "4.1.0", + "typescript": "4.7.4" }, "resolutions": { - "sqlite3/node-gyp": "^8.4.1" + "sqlite3/node-gyp": "8.4.1", + "@types/react": "18.0.17", + "@types/react-dom": "18.0.6" }, "config": { "commitizen": { @@ -165,10 +184,6 @@ "@commitlint/config-conventional" ] }, - "prettier": { - "singleQuote": true, - "trailingComma": "es5" - }, "release": { "plugins": [ "@semantic-release/commit-analyzer", diff --git a/renovate.json b/renovate.json new file mode 100644 index 000000000..bc68da3a1 --- /dev/null +++ b/renovate.json @@ -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" + } + ] +} diff --git a/server/api/animelist.ts b/server/api/animelist.ts index 20eb2f60e..740f67250 100644 --- a/server/api/animelist.ts +++ b/server/api/animelist.ts @@ -1,8 +1,8 @@ +import logger from '@server/logger'; import axios from 'axios'; -import xml2js from 'xml2js'; import fs, { promises as fsp } from 'fs'; 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 // 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); -// Anime-List xml files are community maintained mappings that Hama agent uses to map AniDB IDs to TVDB/TMDb IDs +// Anime-List xml files are community maintained mappings that Hama agent uses to map AniDB IDs to TVDB/TMDB IDs // https://github.com/Anime-Lists/anime-lists/ interface AnimeMapping { diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts index 2a1d94950..cc1e429ff 100644 --- a/server/api/externalapi.ts +++ b/server/api/externalapi.ts @@ -1,5 +1,7 @@ -import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; -import NodeCache from 'node-cache'; +import type { AxiosInstance, AxiosRequestConfig } from 'axios'; +import axios from 'axios'; +import rateLimit from 'axios-rate-limit'; +import type NodeCache from 'node-cache'; // 5 minute default TTL (in seconds) const DEFAULT_TTL = 300; @@ -10,6 +12,10 @@ const DEFAULT_ROLLING_BUFFER = 10000; interface ExternalAPIOptions { nodeCache?: NodeCache; headers?: Record; + rateLimit?: { + maxRPS: number; + maxRequests: number; + }; } class ExternalAPI { @@ -31,6 +37,14 @@ class ExternalAPI { ...options.headers, }, }); + + if (options.rateLimit) { + this.axios = rateLimit(this.axios, { + maxRequests: options.rateLimit.maxRequests, + maxRPS: options.rateLimit.maxRPS, + }); + } + this.baseUrl = baseUrl; this.cache = options.nodeCache; } diff --git a/server/api/github.ts b/server/api/github.ts index a2a71b41f..86539903b 100644 --- a/server/api/github.ts +++ b/server/api/github.ts @@ -1,5 +1,5 @@ -import cacheManager from '../lib/cache'; -import logger from '../logger'; +import cacheManager from '@server/lib/cache'; +import logger from '@server/logger'; import ExternalAPI from './externalapi'; interface GitHubRelease { diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index 73278387a..90aae4852 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -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 { getSettings, Library, PlexSettings } from '../lib/settings'; -import logger from '../logger'; export interface PlexLibraryItem { ratingKey: string; @@ -130,7 +131,6 @@ class PlexAPI { }); } - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types public async getStatus() { return await this.plexClient.query('/'); } diff --git a/server/api/plextv.ts b/server/api/plextv.ts index 1733a85a6..76ee66188 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -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 { PlexDevice } from '../interfaces/api/plexInterfaces'; -import { getSettings } from '../lib/settings'; -import logger from '../logger'; +import ExternalAPI from './externalapi'; interface PlexAccountResponse { user: PlexUser; @@ -111,20 +112,54 @@ interface UsersResponse { }; } -class PlexTvAPI { +interface WatchlistResponse { + MediaContainer: { + totalSize: number; + Metadata?: { + ratingKey: string; + }[]; + }; +} + +interface MetadataResponse { + MediaContainer: { + Metadata: { + ratingKey: string; + type: 'movie' | 'show'; + title: string; + Guid: { + id: `imdb://tt${number}` | `tmdb://${number}` | `tvdb://${number}`; + }[]; + }[]; + }; +} + +export interface PlexWatchlistItem { + ratingKey: string; + tmdbId: number; + tvdbId?: number; + type: 'movie' | 'show'; + title: string; +} + +class PlexTvAPI extends ExternalAPI { private authToken: string; - private axios: AxiosInstance; constructor(authToken: string) { + super( + 'https://plex.tv', + {}, + { + headers: { + 'X-Plex-Token': authToken, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + nodeCache: cacheManager.getCache('plextv').data, + } + ); + this.authToken = authToken; - this.axios = axios.create({ - baseURL: 'https://plex.tv', - headers: { - 'X-Plex-Token': this.authToken, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - }); } public async getDevices(): Promise { @@ -252,6 +287,83 @@ class PlexTvAPI { )) as UsersResponse; return parsedXml; } + + public async getWatchlist({ + offset = 0, + size = 20, + }: { offset?: number; size?: number } = {}): Promise<{ + offset: number; + size: number; + totalSize: number; + items: PlexWatchlistItem[]; + }> { + try { + const response = await this.axios.get( + '/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( + `/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; diff --git a/server/api/rottentomatoes.ts b/server/api/rottentomatoes.ts index b9b00e108..e190b7b97 100644 --- a/server/api/rottentomatoes.ts +++ b/server/api/rottentomatoes.ts @@ -1,4 +1,4 @@ -import cacheManager from '../lib/cache'; +import cacheManager from '@server/lib/cache'; import ExternalAPI from './externalapi'; interface RTSearchResult { diff --git a/server/api/servarr/base.ts b/server/api/servarr/base.ts index 9e4559339..2b8ec4cb8 100644 --- a/server/api/servarr/base.ts +++ b/server/api/servarr/base.ts @@ -1,6 +1,7 @@ -import cacheManager, { AvailableCacheIds } from '../../lib/cache'; -import { DVRSettings } from '../../lib/settings'; -import ExternalAPI from '../externalapi'; +import ExternalAPI from '@server/api/externalapi'; +import type { AvailableCacheIds } from '@server/lib/cache'; +import cacheManager from '@server/lib/cache'; +import type { DVRSettings } from '@server/lib/settings'; export interface SystemStatus { version: string; diff --git a/server/api/servarr/radarr.ts b/server/api/servarr/radarr.ts index 7305baf09..1637a8d8e 100644 --- a/server/api/servarr/radarr.ts +++ b/server/api/servarr/radarr.ts @@ -1,4 +1,4 @@ -import logger from '../../logger'; +import logger from '@server/logger'; import ServarrBase from './base'; export interface RadarrMovieOptions { @@ -69,7 +69,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { return response.data[0]; } catch (e) { - logger.error('Error retrieving movie by TMDb ID', { + logger.error('Error retrieving movie by TMDB ID', { label: 'Radarr API', errorMessage: e.message, tmdbId: id, diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index 7440d2786..a5b9c1e8d 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -1,4 +1,4 @@ -import logger from '../../logger'; +import logger from '@server/logger'; import ServarrBase from './base'; interface SonarrSeason { diff --git a/server/api/tautulli.ts b/server/api/tautulli.ts index bb7f37235..0e5e07071 100644 --- a/server/api/tautulli.ts +++ b/server/api/tautulli.ts @@ -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 { User } from '../entity/User'; -import { TautulliSettings } from '../lib/settings'; -import logger from '../logger'; export interface TautulliHistoryRecord { date: number; diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index b5060c030..ea05b8ab9 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -1,7 +1,7 @@ +import ExternalAPI from '@server/api/externalapi'; +import cacheManager from '@server/lib/cache'; import { sortBy } from 'lodash'; -import cacheManager from '../../lib/cache'; -import ExternalAPI from '../externalapi'; -import { +import type { TmdbCollection, TmdbExternalIdResponse, TmdbGenre, @@ -92,6 +92,10 @@ class TheMovieDb extends ExternalAPI { }, { nodeCache: cacheManager.getCache('tmdb').data, + rateLimit: { + maxRequests: 20, + maxRPS: 50, + }, } ); this.region = region; @@ -192,7 +196,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch person details: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`); } }; @@ -214,7 +218,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { throw new Error( - `[TMDb] Failed to fetch person combined credits: ${e.message}` + `[TMDB] Failed to fetch person combined credits: ${e.message}` ); } }; @@ -241,7 +245,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch movie details: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`); } }; @@ -267,7 +271,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`); } }; @@ -293,7 +297,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`); } }; @@ -319,7 +323,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); } } @@ -345,7 +349,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); } } @@ -371,7 +375,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch movies by keyword: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`); } } @@ -398,7 +402,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { throw new Error( - `[TMDb] Failed to fetch TV recommendations: ${e.message}` + `[TMDB] Failed to fetch TV recommendations: ${e.message}` ); } } @@ -422,7 +426,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch TV similar: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch TV similar: ${e.message}`); } } @@ -455,7 +459,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); } }; @@ -488,7 +492,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch discover TV: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch discover TV: ${e.message}`); } }; @@ -514,7 +518,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch upcoming movies: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`); } }; @@ -541,7 +545,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); } }; @@ -564,7 +568,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); } }; @@ -587,7 +591,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); } }; @@ -619,7 +623,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to find by external ID: ${e.message}`); + throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`); } } @@ -657,7 +661,7 @@ class TheMovieDb extends ExternalAPI { throw new Error(`No movie or show returned from API for ID ${imdbId}`); } catch (e) { throw new Error( - `[TMDb] Failed to find media using external IMDb ID: ${e.message}` + `[TMDB] Failed to find media using external IMDb ID: ${e.message}` ); } } @@ -687,7 +691,7 @@ class TheMovieDb extends ExternalAPI { throw new Error(`No show returned from API for ID ${tvdbId}`); } catch (e) { throw new Error( - `[TMDb] Failed to get TV show using the external TVDB ID: ${e.message}` + `[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}` ); } } @@ -711,7 +715,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch collection: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`); } } @@ -727,7 +731,7 @@ class TheMovieDb extends ExternalAPI { return regions; } catch (e) { - throw new Error(`[TMDb] Failed to fetch countries: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch countries: ${e.message}`); } } @@ -743,7 +747,7 @@ class TheMovieDb extends ExternalAPI { return languages; } catch (e) { - throw new Error(`[TMDb] Failed to fetch langauges: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch langauges: ${e.message}`); } } @@ -755,7 +759,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch movie studio: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch movie studio: ${e.message}`); } } @@ -765,7 +769,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch TV network: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch TV network: ${e.message}`); } } @@ -816,7 +820,7 @@ class TheMovieDb extends ExternalAPI { return movieGenres; } catch (e) { - throw new Error(`[TMDb] Failed to fetch movie genres: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch movie genres: ${e.message}`); } } @@ -867,7 +871,7 @@ class TheMovieDb extends ExternalAPI { return tvGenres; } catch (e) { - throw new Error(`[TMDb] Failed to fetch TV genres: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch TV genres: ${e.message}`); } } } diff --git a/server/constants/media.ts b/server/constants/media.ts index d9ef9e022..de2bf834d 100644 --- a/server/constants/media.ts +++ b/server/constants/media.ts @@ -2,6 +2,7 @@ export enum MediaRequestStatus { PENDING = 1, APPROVED, DECLINED, + FAILED, } export enum MediaType { diff --git a/ormconfig.js b/server/datasource.ts similarity index 64% rename from ormconfig.js rename to server/datasource.ts index 4122f079e..a68392989 100644 --- a/ormconfig.js +++ b/server/datasource.ts @@ -1,4 +1,8 @@ -const devConfig = { +import 'reflect-metadata'; +import type { DataSourceOptions, EntityTarget, Repository } from 'typeorm'; +import { DataSource } from 'typeorm'; + +const devConfig: DataSourceOptions = { type: 'sqlite', database: process.env.CONFIG_DIRECTORY ? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3` @@ -10,31 +14,30 @@ const devConfig = { entities: ['server/entity/**/*.ts'], migrations: ['server/migration/**/*.ts'], subscribers: ['server/subscriber/**/*.ts'], - cli: { - entitiesDir: 'server/entity', - migrationsDir: 'server/migration', - }, }; -const prodConfig = { +const prodConfig: DataSourceOptions = { type: 'sqlite', database: process.env.CONFIG_DIRECTORY ? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3` : 'config/db/db.sqlite3', synchronize: false, + migrationsRun: false, logging: false, enableWAL: true, entities: ['dist/entity/**/*.js'], migrations: ['dist/migration/**/*.js'], - migrationsRun: false, subscribers: ['dist/subscriber/**/*.js'], - cli: { - entitiesDir: 'dist/entity', - migrationsDir: 'dist/migration', - }, }; -const finalConfig = - process.env.NODE_ENV !== 'production' ? devConfig : prodConfig; +const dataSource = new DataSource( + process.env.NODE_ENV !== 'production' ? devConfig : prodConfig +); -module.exports = finalConfig; +export const getRepository = ( + target: EntityTarget +): Repository => { + return dataSource.getRepository(target); +}; + +export default dataSource; diff --git a/server/entity/Issue.ts b/server/entity/Issue.ts index d8e05c565..fae96967d 100644 --- a/server/entity/Issue.ts +++ b/server/entity/Issue.ts @@ -1,3 +1,5 @@ +import type { IssueType } from '@server/constants/issue'; +import { IssueStatus } from '@server/constants/issue'; import { Column, CreateDateColumn, @@ -7,7 +9,6 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; -import { IssueStatus, IssueType } from '../constants/issue'; import IssueComment from './IssueComment'; import Media from './Media'; import { User } from './User'; diff --git a/server/entity/Media.ts b/server/entity/Media.ts index e0cadeef4..42262aa11 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -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 { AfterLoad, Column, CreateDateColumn, Entity, - getRepository, In, Index, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, } 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 { MediaRequest } from './MediaRequest'; import Season from './Season'; @@ -37,7 +38,7 @@ class Media { } const media = await mediaRepository.find({ - tmdbId: In(finalIds), + where: { tmdbId: In(finalIds) }, }); return media; @@ -56,10 +57,10 @@ class Media { try { const media = await mediaRepository.findOne({ where: { tmdbId: id, mediaType }, - relations: ['requests', 'issues'], + relations: { requests: true, issues: true }, }); - return media; + return media ?? undefined; } catch (e) { logger.error(e.message); return undefined; @@ -152,6 +153,9 @@ class Media { public mediaUrl?: string; public mediaUrl4k?: string; + public iOSPlexUrl?: string; + public iOSPlexUrl4k?: string; + public tautulliUrl?: string; public tautulliUrl4k?: string; @@ -172,36 +176,41 @@ class Media { this.ratingKey }`; + this.iOSPlexUrl = `plex://preplay/?metadataKey=%2Flibrary%2Fmetadata%2F${this.ratingKey}&server=${machineId}`; + if (tautulliUrl) { this.tautulliUrl = `${tautulliUrl}/info?rating_key=${this.ratingKey}`; } - } - if (this.ratingKey4k) { - this.mediaUrl4k = `${ - webAppUrl ? webAppUrl : 'https://app.plex.tv/desktop' - }#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${ - this.ratingKey4k - }`; + if (this.ratingKey4k) { + this.mediaUrl4k = `${ + webAppUrl ? webAppUrl : 'https://app.plex.tv/desktop' + }#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${ + this.ratingKey4k + }`; - if (tautulliUrl) { - this.tautulliUrl4k = `${tautulliUrl}/info?rating_key=${this.ratingKey4k}`; + this.iOSPlexUrl4k = `plex://preplay/?metadataKey=%2Flibrary%2Fmetadata%2F${this.ratingKey4k}&server=${machineId}`; + + 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}`; - } } } diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index f7f821156..eefbc11f3 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -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 { AfterInsert, @@ -6,30 +26,347 @@ import { Column, CreateDateColumn, Entity, - getRepository, ManyToOne, OneToMany, PrimaryGeneratedColumn, RelationCount, UpdateDateColumn, } from 'typeorm'; -import RadarrAPI, { RadarrMovieOptions } from '../api/servarr/radarr'; -import SonarrAPI, { - AddSeriesOptions, - SonarrSeries, -} from '../api/servarr/sonarr'; -import 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 SeasonRequest from './SeasonRequest'; import { User } from './User'; +export class RequestPermissionError extends Error {} +export class QuotaRestrictedError extends Error {} +export class DuplicateMediaRequestError extends Error {} +export class NoSeasonsAvailableError extends Error {} + +type MediaRequestOptions = { + isAutoRequest?: boolean; +}; + @Entity() export class MediaRequest { + public static async request( + requestBody: MediaRequestBody, + user: User, + options: MediaRequestOptions = {} + ): Promise { + 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 + >; + const requestedSeasons = + requestBody.seasons === 'all' + ? tmdbMediaShow.seasons + .map((season) => season.season_number) + .filter((sn) => sn > 0) + : (requestBody.seasons as number[]); + let existingSeasons: number[] = []; + + // We need to check existing requests on this title to make sure we don't double up on seasons that were + // already requested. In the case they were, we just throw out any duplicates but still approve the request. + // (Unless there are no seasons, in which case we abort) + if (media.requests) { + existingSeasons = media.requests + .filter( + (request) => + request.is4k === requestBody.is4k && + request.status !== MediaRequestStatus.DECLINED + ) + .reduce((seasons, request) => { + const combinedSeasons = request.seasons.map( + (season) => season.seasonNumber + ); + + return [...seasons, ...combinedSeasons]; + }, [] as number[]); + } + + // We should also check seasons that are available/partially available but don't have existing requests + if (media.seasons) { + existingSeasons = [ + ...existingSeasons, + ...media.seasons + .filter( + (season) => + season[requestBody.is4k ? 'status4k' : 'status'] !== + MediaStatus.UNKNOWN + ) + .map((season) => season.seasonNumber), + ]; + } + + const finalSeasons = requestedSeasons.filter( + (rs) => !existingSeasons.includes(rs) + ); + + if (finalSeasons.length === 0) { + throw new NoSeasonsAvailableError('No seasons available to request'); + } else if ( + quotas.tv.limit && + finalSeasons.length > (quotas.tv.remaining ?? 0) + ) { + throw new QuotaRestrictedError('Series Quota exceeded.'); + } + + await mediaRepository.save(media); + + const request = new MediaRequest({ + type: MediaType.TV, + media, + requestedBy: requestUser, + // If the user is an admin or has the "auto approve" permission, automatically approve the request + status: user.hasPermission( + [ + requestBody.is4k + ? Permission.AUTO_APPROVE_4K + : Permission.AUTO_APPROVE, + requestBody.is4k + ? Permission.AUTO_APPROVE_4K_TV + : Permission.AUTO_APPROVE_TV, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, + modifiedBy: user.hasPermission( + [ + requestBody.is4k + ? Permission.AUTO_APPROVE_4K + : Permission.AUTO_APPROVE, + requestBody.is4k + ? Permission.AUTO_APPROVE_4K_TV + : Permission.AUTO_APPROVE_TV, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? user + : undefined, + is4k: requestBody.is4k, + serverId: requestBody.serverId, + profileId: requestBody.profileId, + rootFolder: requestBody.rootFolder, + languageProfileId: requestBody.languageProfileId, + tags: requestBody.tags, + seasons: finalSeasons.map( + (sn) => + new SeasonRequest({ + seasonNumber: sn, + status: user.hasPermission( + [ + requestBody.is4k + ? Permission.AUTO_APPROVE_4K + : Permission.AUTO_APPROVE, + requestBody.is4k + ? Permission.AUTO_APPROVE_4K_TV + : Permission.AUTO_APPROVE_TV, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, + }) + ), + isAutoRequest: options.isAutoRequest ?? false, + }); + + await requestRepository.save(request); + return request; + } + } + @PrimaryGeneratedColumn() public id: number; @@ -120,6 +457,9 @@ export class MediaRequest { }) public tags?: number[]; + @Column({ default: false }) + public isAutoRequest: boolean; + constructor(init?: Partial) { Object.assign(this, init); } @@ -147,6 +487,10 @@ export class MediaRequest { } 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_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 media = await mediaRepository.findOne({ where: { id: this.media.id }, - relations: ['requests'], + relations: { requests: true }, }); if (!media) { logger.error('Media data not found', { @@ -272,7 +624,7 @@ export class MediaRequest { const mediaRepository = getRepository(Media); const fullMedia = await mediaRepository.findOneOrFail({ where: { id: this.media.id }, - relations: ['requests'], + relations: { requests: true }, }); if ( @@ -452,10 +804,13 @@ export class MediaRequest { await mediaRepository.save(media); }) .catch(async () => { - media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; - await mediaRepository.save(media); + const requestRepository = getRepository(MediaRequest); + + this.status = MediaRequestStatus.FAILED; + requestRepository.save(this); + logger.warn( - 'Something went wrong sending movie request to Radarr, marking status as UNKNOWN', + 'Something went wrong sending movie request to Radarr, marking status as FAILED', { label: 'Media Request', requestId: this.id, @@ -543,7 +898,7 @@ export class MediaRequest { const media = await mediaRepository.findOne({ where: { id: this.media.id }, - relations: ['requests'], + relations: { requests: true }, }); if (!media) { @@ -670,7 +1025,7 @@ export class MediaRequest { // We grab media again here to make sure we have the latest version of it const media = await mediaRepository.findOne({ where: { id: this.media.id }, - relations: ['requests'], + relations: { requests: true }, }); if (!media) { @@ -685,10 +1040,13 @@ export class MediaRequest { await mediaRepository.save(media); }) .catch(async () => { - media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; - await mediaRepository.save(media); + const requestRepository = getRepository(MediaRequest); + + this.status = MediaRequestStatus.FAILED; + requestRepository.save(this); + logger.warn( - 'Something went wrong sending series request to Sonarr, marking status as UNKNOWN', + 'Something went wrong sending series request to Sonarr, marking status as FAILED', { label: 'Media Request', requestId: this.id, @@ -723,6 +1081,7 @@ export class MediaRequest { const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series'; let event: string | undefined; let notifyAdmin = true; + let notifySystem = true; switch (type) { case Notification.MEDIA_APPROVED: @@ -736,6 +1095,13 @@ export class MediaRequest { case Notification.MEDIA_PENDING: event = `New ${this.is4k ? '4K ' : ''}${mediaType} Request`; break; + case Notification.MEDIA_AUTO_REQUESTED: + event = `${ + this.is4k ? '4K ' : '' + }${mediaType} Request Automatically Submitted`; + notifyAdmin = false; + notifySystem = false; + break; case Notification.MEDIA_AUTO_APPROVED: event = `${ this.is4k ? '4K ' : '' @@ -752,6 +1118,7 @@ export class MediaRequest { media, request: this, notifyAdmin, + notifySystem, notifyUser: notifyAdmin ? undefined : this.requestedBy, event, subject: `${movie.title}${ @@ -770,6 +1137,7 @@ export class MediaRequest { media, request: this, notifyAdmin, + notifySystem, notifyUser: notifyAdmin ? undefined : this.requestedBy, event, subject: `${tv.name}${ diff --git a/server/entity/Season.ts b/server/entity/Season.ts index 77f9c7607..44a83d976 100644 --- a/server/entity/Season.ts +++ b/server/entity/Season.ts @@ -1,12 +1,12 @@ +import { MediaStatus } from '@server/constants/media'; import { - Entity, - PrimaryGeneratedColumn, Column, - ManyToOne, CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; -import { MediaStatus } from '../constants/media'; import Media from './Media'; @Entity() diff --git a/server/entity/SeasonRequest.ts b/server/entity/SeasonRequest.ts index f499406c5..f9eeef501 100644 --- a/server/entity/SeasonRequest.ts +++ b/server/entity/SeasonRequest.ts @@ -1,12 +1,12 @@ +import { MediaRequestStatus } from '@server/constants/media'; import { - Entity, - PrimaryGeneratedColumn, Column, CreateDateColumn, - UpdateDateColumn, + Entity, ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, } from 'typeorm'; -import { MediaRequestStatus } from '../constants/media'; import { MediaRequest } from './MediaRequest'; @Entity() diff --git a/server/entity/Session.ts b/server/entity/Session.ts index e7462c195..ddf851a6e 100644 --- a/server/entity/Session.ts +++ b/server/entity/Session.ts @@ -1,5 +1,5 @@ -import { ISession } from 'connect-typeorm'; -import { Index, Column, PrimaryColumn, Entity } from 'typeorm'; +import type { ISession } from 'connect-typeorm'; +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; @Entity() export class Session implements ISession { diff --git a/server/entity/User.ts b/server/entity/User.ts index 7fa6dc67d..b5f781109 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -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 { randomUUID } from 'crypto'; import path from 'path'; @@ -7,8 +17,6 @@ import { Column, CreateDateColumn, Entity, - getRepository, - MoreThan, Not, OneToMany, OneToOne, @@ -16,17 +24,6 @@ import { RelationCount, UpdateDateColumn, } 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 { MediaRequest } from './MediaRequest'; import SeasonRequest from './SeasonRequest'; @@ -270,13 +267,14 @@ export class User { if (movieQuotaDays) { movieDate.setDate(movieDate.getDate() - movieQuotaDays); } - const movieQuotaStartDate = movieDate.toJSON(); const movieQuotaUsed = movieQuotaLimit ? await requestRepository.count({ where: { - requestedBy: this, - createdAt: MoreThan(movieQuotaStartDate), + requestedBy: { + id: this.id, + }, + createdAt: AfterDate(movieDate), type: MediaType.MOVIE, status: Not(MediaRequestStatus.DECLINED), }, diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index 08397b12f..771c382d1 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -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 { Column, Entity, @@ -5,9 +8,6 @@ import { OneToOne, PrimaryGeneratedColumn, } from 'typeorm'; -import { NotificationAgentTypes } from '../interfaces/api/userSettingsInterfaces'; -import { hasNotificationType, Notification } from '../lib/notifications'; -import { NotificationAgentKey } from '../lib/settings'; import { User } from './User'; export const ALL_NOTIFICATIONS = Object.values(Notification) @@ -57,6 +57,12 @@ export class UserSettings { @Column({ nullable: true }) public telegramSendSilently?: boolean; + @Column({ nullable: true }) + public watchlistSyncMovies?: boolean; + + @Column({ nullable: true }) + public watchlistSyncTv?: boolean; + @Column({ type: 'text', nullable: true, diff --git a/server/index.ts b/server/index.ts index 3f4e55035..615e789bf 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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 { TypeormStore } from 'connect-typeorm/out'; import cookieParser from 'cookie-parser'; import csurf from 'csurf'; -import express, { NextFunction, Request, Response } from 'express'; +import type { NextFunction, Request, Response } from 'express'; +import express from 'express'; import * as OpenApiValidator from 'express-openapi-validator'; -import session, { Store } from 'express-session'; +import type { Store } from 'express-session'; +import session from 'express-session'; import next from 'next'; import path from 'path'; import swaggerUi from 'swagger-ui-express'; -import { createConnection, getRepository } from 'typeorm'; import YAML from 'yamljs'; -import PlexAPI from './api/plexapi'; -import { 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'); @@ -40,7 +43,7 @@ const handle = app.getRequestHandler(); app .prepare() .then(async () => { - const dbConnection = await createConnection(); + const dbConnection = await dataSource.initialize(); // Run migrations in production if (process.env.NODE_ENV === 'production') { @@ -51,6 +54,7 @@ app // Load Settings const settings = getSettings().load(); + restartFlag.initializeSettings(settings.main); // Migrate library types if ( @@ -59,8 +63,8 @@ app ) { const userRepository = getRepository(User); const admin = await userRepository.findOne({ - select: ['id', 'plexToken'], - order: { id: 'ASC' }, + select: { id: true, plexToken: true }, + where: { id: 1 }, }); if (admin) { diff --git a/server/interfaces/api/discoverInterfaces.ts b/server/interfaces/api/discoverInterfaces.ts index db90e55d2..89cb7426f 100644 --- a/server/interfaces/api/discoverInterfaces.ts +++ b/server/interfaces/api/discoverInterfaces.ts @@ -3,3 +3,17 @@ export interface GenreSliderItem { name: 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[]; +} diff --git a/server/interfaces/api/issueInterfaces.ts b/server/interfaces/api/issueInterfaces.ts index bd17f1958..e5b3643cd 100644 --- a/server/interfaces/api/issueInterfaces.ts +++ b/server/interfaces/api/issueInterfaces.ts @@ -1,5 +1,5 @@ -import Issue from '../../entity/Issue'; -import { PaginatedResponse } from './common'; +import type Issue from '@server/entity/Issue'; +import type { PaginatedResponse } from './common'; export interface IssueResultsResponse extends PaginatedResponse { results: Issue[]; diff --git a/server/interfaces/api/mediaInterfaces.ts b/server/interfaces/api/mediaInterfaces.ts index d17716d20..263d859ad 100644 --- a/server/interfaces/api/mediaInterfaces.ts +++ b/server/interfaces/api/mediaInterfaces.ts @@ -1,6 +1,6 @@ -import type Media from '../../entity/Media'; -import { User } from '../../entity/User'; -import { PaginatedResponse } from './common'; +import type Media from '@server/entity/Media'; +import type { User } from '@server/entity/User'; +import type { PaginatedResponse } from './common'; export interface MediaResultsResponse extends PaginatedResponse { results: Media[]; diff --git a/server/interfaces/api/personInterfaces.ts b/server/interfaces/api/personInterfaces.ts index 19d3468ce..c52ad0c6a 100644 --- a/server/interfaces/api/personInterfaces.ts +++ b/server/interfaces/api/personInterfaces.ts @@ -1,4 +1,4 @@ -import { PersonCreditCast, PersonCreditCrew } from '../../models/Person'; +import type { PersonCreditCast, PersonCreditCrew } from '@server/models/Person'; export interface PersonCombinedCreditsResponse { id: number; diff --git a/server/interfaces/api/plexInterfaces.ts b/server/interfaces/api/plexInterfaces.ts index 5373cb58a..32be891e9 100644 --- a/server/interfaces/api/plexInterfaces.ts +++ b/server/interfaces/api/plexInterfaces.ts @@ -1,4 +1,4 @@ -import { PlexSettings } from '../../lib/settings'; +import type { PlexSettings } from '@server/lib/settings'; export interface PlexStatus { settings: PlexSettings; diff --git a/server/interfaces/api/requestInterfaces.ts b/server/interfaces/api/requestInterfaces.ts index ca39515bd..89863cb04 100644 --- a/server/interfaces/api/requestInterfaces.ts +++ b/server/interfaces/api/requestInterfaces.ts @@ -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 { MediaRequest } from '../../entity/MediaRequest'; export interface RequestResultsResponse extends PaginatedResponse { results: MediaRequest[]; } + +export type MediaRequestBody = { + mediaType: MediaType; + mediaId: number; + tvdbId?: number; + seasons?: number[] | 'all'; + is4k?: boolean; + serverId?: number; + profileId?: number; + rootFolder?: string; + languageProfileId?: number; + userId?: number; + tags?: number[]; +}; diff --git a/server/interfaces/api/serviceInterfaces.ts b/server/interfaces/api/serviceInterfaces.ts index 1188f24c0..3b430b0b5 100644 --- a/server/interfaces/api/serviceInterfaces.ts +++ b/server/interfaces/api/serviceInterfaces.ts @@ -1,5 +1,5 @@ -import { QualityProfile, RootFolder, Tag } from '../../api/servarr/base'; -import { LanguageProfile } from '../../api/servarr/sonarr'; +import type { QualityProfile, RootFolder, Tag } from '@server/api/servarr/base'; +import type { LanguageProfile } from '@server/api/servarr/sonarr'; export interface ServiceCommonServer { id: number; diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index c486a1b46..bafd15b1f 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -59,4 +59,5 @@ export interface StatusResponse { commitTag: string; updateAvailable: boolean; commitsBehind: number; + restartRequired: boolean; } diff --git a/server/interfaces/api/userInterfaces.ts b/server/interfaces/api/userInterfaces.ts index e5f564826..2ac75c5e1 100644 --- a/server/interfaces/api/userInterfaces.ts +++ b/server/interfaces/api/userInterfaces.ts @@ -1,7 +1,7 @@ -import Media from '../../entity/Media'; -import { MediaRequest } from '../../entity/MediaRequest'; -import type { User } from '../../entity/User'; -import { PaginatedResponse } from './common'; +import type Media from '@server/entity/Media'; +import type { MediaRequest } from '@server/entity/MediaRequest'; +import type { User } from '@server/entity/User'; +import type { PaginatedResponse } from './common'; export interface UserResultsResponse extends PaginatedResponse { results: User[]; @@ -23,6 +23,7 @@ export interface QuotaResponse { movie: QuotaStatus; tv: QuotaStatus; } + export interface UserWatchDataResponse { recentlyWatched: Media[]; playCount: number; diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index d0a0ff9f8..e54f0070b 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -1,4 +1,4 @@ -import { NotificationAgentKey } from '../../lib/settings'; +import type { NotificationAgentKey } from '@server/lib/settings'; export interface UserSettingsGeneralResponse { username?: string; @@ -15,6 +15,8 @@ export interface UserSettingsGeneralResponse { globalMovieQuotaLimit?: number; globalTvQuotaLimit?: number; globalTvQuotaDays?: number; + watchlistSyncMovies?: boolean; + watchlistSyncTv?: boolean; } export type NotificationAgentTypes = Record; diff --git a/server/job/jellyfinsync/index.ts b/server/job/jellyfinsync/index.ts index 23843d924..85c8dcc58 100644 --- a/server/job/jellyfinsync/index.ts +++ b/server/job/jellyfinsync/index.ts @@ -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 { 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 UPDATE_RATE = 4 * 1000; @@ -552,6 +554,7 @@ class JobJellyfinSync { this.running = true; const userRepository = getRepository(User); const admin = await userRepository.findOne({ + where: { id: 1 }, select: [ 'id', 'jellyfinAuthToken', diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 181d540d3..5743e3371 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -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 { 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'; 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 scheduledJobs.push({ id: 'radarr-scan', diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 7782a05a8..e81466629 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -6,7 +6,8 @@ export type AvailableCacheIds = | 'sonarr' | 'rt' | 'github' - | 'plexguid'; + | 'plexguid' + | 'plextv'; const DEFAULT_TTL = 300; const DEFAULT_CHECK_PERIOD = 120; @@ -58,6 +59,10 @@ class CacheManager { stdTtl: 86400 * 7, // 1 week cache checkPeriod: 60 * 30, }), + plextv: new Cache('plextv', 'Plex TV', { + stdTtl: 86400 * 7, // 1 week cache + checkPeriod: 60, + }), }; public getCache(id: AvailableCacheIds): Cache { diff --git a/server/lib/downloadtracker.ts b/server/lib/downloadtracker.ts index c62e189d8..4aef968f1 100644 --- a/server/lib/downloadtracker.ts +++ b/server/lib/downloadtracker.ts @@ -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 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 { mediaType: MediaType; diff --git a/server/lib/email/index.ts b/server/lib/email/index.ts index 1274d6a8b..c38892ae2 100644 --- a/server/lib/email/index.ts +++ b/server/lib/email/index.ts @@ -1,7 +1,8 @@ +import type { NotificationAgentEmail } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; import Email from 'email-templates'; import nodemailer from 'nodemailer'; import { URL } from 'url'; -import { getSettings, NotificationAgentEmail } from '../settings'; import { openpgpEncrypt } from './openpgpEncrypt'; class PreparedEmail extends Email { diff --git a/server/lib/email/openpgpEncrypt.ts b/server/lib/email/openpgpEncrypt.ts index c067a7d58..dd320ea38 100644 --- a/server/lib/email/openpgpEncrypt.ts +++ b/server/lib/email/openpgpEncrypt.ts @@ -1,7 +1,8 @@ +import logger from '@server/logger'; import { randomBytes } from 'crypto'; import * as openpgp from 'openpgp'; -import { Transform, TransformCallback } from 'stream'; -import logger from '../../logger'; +import type { TransformCallback } from 'stream'; +import { Transform } from 'stream'; interface EncryptorOptions { signingKey?: string; @@ -26,7 +27,7 @@ class PGPEncryptor extends Transform { // just save the whole message _transform = ( - chunk: any, + chunk: Uint8Array, _encoding: BufferEncoding, callback: TransformCallback ): void => { @@ -184,6 +185,9 @@ class PGPEncryptor extends Transform { } export const openpgpEncrypt = (options: EncryptorOptions) => { + // Disabling this line because I don't want to fix it but I am tired + // of seeing the lint warning + // eslint-disable-next-line @typescript-eslint/no-explicit-any return function (mail: any, callback: () => unknown): void { if (!options.encryptionKeys.length) { setImmediate(callback); diff --git a/server/lib/notifications/agents/agent.ts b/server/lib/notifications/agents/agent.ts index edfa1262d..d2b0b1656 100644 --- a/server/lib/notifications/agents/agent.ts +++ b/server/lib/notifications/agents/agent.ts @@ -1,14 +1,15 @@ -import { Notification } from '..'; -import type Issue from '../../../entity/Issue'; -import IssueComment from '../../../entity/IssueComment'; -import Media from '../../../entity/Media'; -import { MediaRequest } from '../../../entity/MediaRequest'; -import { User } from '../../../entity/User'; -import { NotificationAgentConfig } from '../../settings'; +import type Issue from '@server/entity/Issue'; +import type IssueComment from '@server/entity/IssueComment'; +import type Media from '@server/entity/Media'; +import type { MediaRequest } from '@server/entity/MediaRequest'; +import type { User } from '@server/entity/User'; +import type { NotificationAgentConfig } from '@server/lib/settings'; +import type { Notification } from '..'; export interface NotificationPayload { event?: string; subject: string; + notifySystem: boolean; notifyAdmin: boolean; notifyUser?: User; media?: Media; diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index 321200350..67a278bfb 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -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 { getRepository } from 'typeorm'; import { hasNotificationType, Notification, shouldSendAdminNotification, } from '..'; -import { IssueStatus, IssueTypeName } from '../../../constants/issue'; -import { User } from '../../../entity/User'; -import logger from '../../../logger'; -import { - getSettings, - NotificationAgentDiscord, - NotificationAgentKey, -} from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; enum EmbedColors { DEFAULT = 0, @@ -245,7 +243,10 @@ class DiscordAgent ): Promise { const settings = this.getSettings(); - if (!hasNotificationType(type, settings.types ?? 0)) { + if ( + !payload.notifySystem || + !hasNotificationType(type, settings.types ?? 0) + ) { return true; } diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index cbed472fa..59c5b4aa7 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -1,19 +1,17 @@ -import { EmailOptions } from 'email-templates'; -import path from 'path'; -import { getRepository } from 'typeorm'; -import { Notification, shouldSendAdminNotification } from '..'; -import { IssueType, IssueTypeName } from '../../../constants/issue'; -import { MediaType } from '../../../constants/media'; -import { User } from '../../../entity/User'; -import logger from '../../../logger'; -import PreparedEmail from '../../email'; -import { - getSettings, - NotificationAgentEmail, - NotificationAgentKey, -} from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import { IssueType, IssueTypeName } from '@server/constants/issue'; +import { MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import PreparedEmail from '@server/lib/email'; +import type { NotificationAgentEmail } from '@server/lib/settings'; +import { getSettings, NotificationAgentKey } from '@server/lib/settings'; +import logger from '@server/logger'; +import type { EmailOptions } from 'email-templates'; 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 extends BaseAgent @@ -84,6 +82,11 @@ class EmailAgent is4k ? 'in 4K ' : '' }is pending approval:`; 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: body = `Your request for the following ${mediaType} ${ is4k ? 'in 4K ' : '' diff --git a/server/lib/notifications/agents/gotify.ts b/server/lib/notifications/agents/gotify.ts index ecd54ce75..d07caac41 100644 --- a/server/lib/notifications/agents/gotify.ts +++ b/server/lib/notifications/agents/gotify.ts @@ -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 { hasNotificationType, Notification } from '..'; -import { IssueStatus, IssueTypeName } from '../../../constants/issue'; -import logger from '../../../logger'; -import { getSettings, NotificationAgentGotify } from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; interface GotifyPayload { title: string; message: string; priority: number; - extras: any; + extras: Record; } class GotifyAgent @@ -115,7 +117,10 @@ class GotifyAgent ): Promise { const settings = this.getSettings(); - if (!hasNotificationType(type, settings.types ?? 0)) { + if ( + !payload.notifySystem || + !hasNotificationType(type, settings.types ?? 0) + ) { return true; } diff --git a/server/lib/notifications/agents/lunasea.ts b/server/lib/notifications/agents/lunasea.ts index 0269e2600..885b038ca 100644 --- a/server/lib/notifications/agents/lunasea.ts +++ b/server/lib/notifications/agents/lunasea.ts @@ -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 { hasNotificationType, Notification } from '..'; -import { IssueStatus, IssueType } from '../../../constants/issue'; -import { MediaStatus } from '../../../constants/media'; -import logger from '../../../logger'; -import { getSettings, NotificationAgentLunaSea } from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; class LunaSeaAgent extends BaseAgent @@ -85,7 +87,10 @@ class LunaSeaAgent ): Promise { const settings = this.getSettings(); - if (!hasNotificationType(type, settings.types ?? 0)) { + if ( + !payload.notifySystem || + !hasNotificationType(type, settings.types ?? 0) + ) { return true; } diff --git a/server/lib/notifications/agents/pushbullet.ts b/server/lib/notifications/agents/pushbullet.ts index b7bc1919f..eed4fda91 100644 --- a/server/lib/notifications/agents/pushbullet.ts +++ b/server/lib/notifications/agents/pushbullet.ts @@ -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 { getRepository } from 'typeorm'; import { hasNotificationType, Notification, shouldSendAdminNotification, } from '..'; -import { IssueStatus, IssueTypeName } from '../../../constants/issue'; -import { User } from '../../../entity/User'; -import logger from '../../../logger'; -import { - getSettings, - NotificationAgentKey, - NotificationAgentPushbullet, -} from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; interface PushbulletPayload { type: string; @@ -54,6 +53,12 @@ class PushbulletAgent let status = ''; switch (type) { + case Notification.MEDIA_AUTO_REQUESTED: + status = + payload.media?.status === MediaStatus.PENDING + ? 'Pending Approval' + : 'Processing'; + break; case Notification.MEDIA_PENDING: status = 'Pending Approval'; break; @@ -106,6 +111,7 @@ class PushbulletAgent // Send system notification if ( + payload.notifySystem && hasNotificationType(type, settings.types ?? 0) && settings.enabled && settings.options.accessToken diff --git a/server/lib/notifications/agents/pushover.ts b/server/lib/notifications/agents/pushover.ts index f8364c3f2..d8deb1bdc 100644 --- a/server/lib/notifications/agents/pushover.ts +++ b/server/lib/notifications/agents/pushover.ts @@ -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 { getRepository } from 'typeorm'; import { hasNotificationType, Notification, shouldSendAdminNotification, } from '..'; -import { IssueStatus, IssueTypeName } from '../../../constants/issue'; -import { User } from '../../../entity/User'; -import logger from '../../../logger'; -import { - getSettings, - NotificationAgentKey, - NotificationAgentPushover, -} from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; interface PushoverPayload { token: string; @@ -63,6 +62,12 @@ class PushoverAgent let status = ''; switch (type) { + case Notification.MEDIA_AUTO_REQUESTED: + status = + payload.media?.status === MediaStatus.PENDING + ? 'Pending Approval' + : 'Processing'; + break; case Notification.MEDIA_PENDING: status = 'Pending Approval'; break; @@ -137,6 +142,7 @@ class PushoverAgent // Send system notification if ( + payload.notifySystem && hasNotificationType(type, settings.types ?? 0) && settings.enabled && settings.options.accessToken && diff --git a/server/lib/notifications/agents/slack.ts b/server/lib/notifications/agents/slack.ts index ca10c269c..9447cda35 100644 --- a/server/lib/notifications/agents/slack.ts +++ b/server/lib/notifications/agents/slack.ts @@ -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 { hasNotificationType, Notification } from '..'; -import { IssueStatus, IssueTypeName } from '../../../constants/issue'; -import logger from '../../../logger'; -import { getSettings, NotificationAgentSlack } from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; interface EmbedField { type: 'plain_text' | 'mrkdwn'; @@ -223,7 +225,10 @@ class SlackAgent ): Promise { const settings = this.getSettings(); - if (!hasNotificationType(type, settings.types ?? 0)) { + if ( + !payload.notifySystem || + !hasNotificationType(type, settings.types ?? 0) + ) { return true; } diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts index 3450a3c2a..7d7062122 100644 --- a/server/lib/notifications/agents/telegram.ts +++ b/server/lib/notifications/agents/telegram.ts @@ -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 { getRepository } from 'typeorm'; import { hasNotificationType, Notification, shouldSendAdminNotification, } from '..'; -import { IssueStatus, IssueTypeName } from '../../../constants/issue'; -import { User } from '../../../entity/User'; -import logger from '../../../logger'; -import { - getSettings, - NotificationAgentKey, - NotificationAgentTelegram, -} from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; interface TelegramMessagePayload { text: string; @@ -81,6 +80,12 @@ class TelegramAgent let status = ''; switch (type) { + case Notification.MEDIA_AUTO_REQUESTED: + status = + payload.media?.status === MediaStatus.PENDING + ? 'Pending Approval' + : 'Processing'; + break; case Notification.MEDIA_PENDING: status = 'Pending Approval'; break; @@ -159,6 +164,7 @@ class TelegramAgent // Send system notification if ( + payload.notifySystem && hasNotificationType(type, settings.types ?? 0) && settings.options.chatId ) { diff --git a/server/lib/notifications/agents/webhook.ts b/server/lib/notifications/agents/webhook.ts index ba2bf5e59..461cd37fd 100644 --- a/server/lib/notifications/agents/webhook.ts +++ b/server/lib/notifications/agents/webhook.ts @@ -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 { get } from 'lodash'; import { hasNotificationType, Notification } from '..'; -import { IssueStatus, IssueType } from '../../../constants/issue'; -import { MediaStatus } from '../../../constants/media'; -import logger from '../../../logger'; -import { getSettings, NotificationAgentWebhook } from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; type KeyMapFunction = ( payload: NotificationPayload, @@ -162,7 +164,10 @@ class WebhookAgent ): Promise { const settings = this.getSettings(); - if (!hasNotificationType(type, settings.types ?? 0)) { + if ( + !payload.notifySystem || + !hasNotificationType(type, settings.types ?? 0) + ) { return true; } diff --git a/server/lib/notifications/agents/webpush.ts b/server/lib/notifications/agents/webpush.ts index c87d9496c..275a77e8e 100644 --- a/server/lib/notifications/agents/webpush.ts +++ b/server/lib/notifications/agents/webpush.ts @@ -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 { Notification, shouldSendAdminNotification } from '..'; -import { IssueType, IssueTypeName } from '../../../constants/issue'; -import { MediaType } from '../../../constants/media'; -import { User } from '../../../entity/User'; -import { UserPushSubscription } from '../../../entity/UserPushSubscription'; -import logger from '../../../logger'; -import { - getSettings, - NotificationAgentConfig, - NotificationAgentKey, -} from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; interface PushNotificationPayload { notificationType: string; @@ -59,6 +57,11 @@ class WebPushAgent case Notification.TEST_NOTIFICATION: message = payload.message; break; + case Notification.MEDIA_AUTO_REQUESTED: + message = `Automatically submitted a new ${ + is4k ? '4K ' : '' + }${mediaType} request.`; + break; case Notification.MEDIA_APPROVED: message = `Your ${ is4k ? '4K ' : '' @@ -160,7 +163,7 @@ class WebPushAgent true) ) { const notifySubs = await userPushSubRepository.find({ - where: { user: payload.notifyUser.id }, + where: { user: { id: payload.notifyUser.id } }, }); pushSubs.push(...notifySubs); diff --git a/server/lib/notifications/index.ts b/server/lib/notifications/index.ts index b8111d02f..71aea8fe9 100644 --- a/server/lib/notifications/index.ts +++ b/server/lib/notifications/index.ts @@ -1,6 +1,6 @@ -import { User } from '../../entity/User'; -import logger from '../../logger'; -import { Permission } from '../permissions'; +import type { User } from '@server/entity/User'; +import { Permission } from '@server/lib/permissions'; +import logger from '@server/logger'; import type { NotificationAgent, NotificationPayload } from './agents/agent'; export enum Notification { @@ -16,6 +16,7 @@ export enum Notification { ISSUE_COMMENT = 512, ISSUE_RESOLVED = 1024, ISSUE_REOPENED = 2048, + MEDIA_AUTO_REQUESTED = 4096, } export const hasNotificationType = ( diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts index 95160d380..4a4a90d84 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -22,6 +22,11 @@ export enum Permission { MANAGE_ISSUES = 1048576, VIEW_ISSUES = 2097152, CREATE_ISSUES = 4194304, + AUTO_REQUEST = 8388608, + AUTO_REQUEST_MOVIE = 16777216, + AUTO_REQUEST_TV = 33554432, + RECENT_VIEW = 67108864, + WATCHLIST_VIEW = 134217728, } export interface PermissionCheckOptions { diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts index f76ea92b0..f0f3db7e6 100644 --- a/server/lib/scanners/baseScanner.ts +++ b/server/lib/scanners/baseScanner.ts @@ -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 { 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) const BUNDLE_SIZE = 20; @@ -210,7 +210,7 @@ class BaseScanner { } /** - * processShow takes a TMDb ID and an array of ProcessableSeasons, which + * processShow takes a TMDB ID and an array of ProcessableSeasons, which * should include the total episodes a sesaon has + the total available * episodes that each season currently has. Unlike processMovie, this method * does not take an `is4k` option. We handle both the 4k _and_ non 4k status diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts index cd8dbd76a..73e4d9b26 100644 --- a/server/lib/scanners/plex/index.ts +++ b/server/lib/scanners/plex/index.ts @@ -1,17 +1,20 @@ -import { uniqWith } from 'lodash'; -import { getRepository } from 'typeorm'; -import animeList from '../../../api/animelist'; -import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../../api/plexapi'; -import { TmdbTvDetails } from '../../../api/themoviedb/interfaces'; -import { User } from '../../../entity/User'; -import cacheManager from '../../cache'; -import { getSettings, Library } from '../../settings'; -import BaseScanner, { +import animeList from '@server/api/animelist'; +import type { PlexLibraryItem, PlexMetadata } from '@server/api/plexapi'; +import PlexAPI from '@server/api/plexapi'; +import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import cacheManager from '@server/lib/cache'; +import type { MediaIds, ProcessableSeason, RunnableScanner, 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 tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/); @@ -59,8 +62,8 @@ class PlexScanner try { const userRepository = getRepository(User); const admin = await userRepository.findOne({ - select: ['id', 'plexToken'], - order: { id: 'ASC' }, + select: { id: true, plexToken: true }, + where: { id: 1 }, }); if (!admin) { @@ -141,7 +144,9 @@ class PlexScanner 'info' ); } catch (e) { - this.log('Scan interrupted', 'error', { errorMessage: e.message }); + this.log('Scan interrupted', 'error', { + errorMessage: e.message, + }); } finally { this.endRun(sessionId); } @@ -369,7 +374,7 @@ class PlexScanner } }); - // If we got an IMDb ID, but no TMDb ID, lookup the TMDb ID with the IMDb ID + // If we got an IMDb ID, but no TMDB ID, lookup the TMDB ID with the IMDb ID if (mediaIds.imdbId && !mediaIds.tmdbId) { const tmdbMedia = await this.tmdb.getMediaByImdbId({ imdbId: mediaIds.imdbId, @@ -390,7 +395,7 @@ class PlexScanner }); mediaIds.tmdbId = tmdbMedia.id; } - // Check if the agent is TMDb + // Check if the agent is TMDB } else if (plexitem.guid.match(tmdbRegex)) { const tmdbMatch = plexitem.guid.match(tmdbRegex); if (tmdbMatch) { @@ -409,7 +414,7 @@ class PlexScanner mediaIds.tvdbId = Number(matchedtvdb[1]); mediaIds.tmdbId = show.id; } - // Check if the agent (for shows) is TMDb + // Check if the agent (for shows) is TMDB } else if (plexitem.guid.match(tmdbShowRegex)) { const matchedtmdb = plexitem.guid.match(tmdbShowRegex); if (matchedtmdb) { @@ -484,10 +489,10 @@ class PlexScanner } if (!mediaIds.tmdbId) { - throw new Error('Unable to find TMDb ID'); + throw new Error('Unable to find TMDB ID'); } - // We check above if we have the TMDb ID, so we can safely assert the type below + // We check above if we have the TMDB ID, so we can safely assert the type below return mediaIds as MediaIds; } diff --git a/server/lib/scanners/radarr/index.ts b/server/lib/scanners/radarr/index.ts index 5f47b9d97..bc299d7b1 100644 --- a/server/lib/scanners/radarr/index.ts +++ b/server/lib/scanners/radarr/index.ts @@ -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 RadarrAPI, { RadarrMovie } from '../../../api/servarr/radarr'; -import { getSettings, RadarrSettings } from '../../settings'; -import BaseScanner, { RunnableScanner, StatusBase } from '../baseScanner'; type SyncStatus = StatusBase & { currentServer: RadarrSettings; diff --git a/server/lib/scanners/sonarr/index.ts b/server/lib/scanners/sonarr/index.ts index 044f74ec7..3256c9482 100644 --- a/server/lib/scanners/sonarr/index.ts +++ b/server/lib/scanners/sonarr/index.ts @@ -1,14 +1,17 @@ -import { uniqWith } from 'lodash'; -import { getRepository } from 'typeorm'; -import SonarrAPI, { SonarrSeries } from '../../../api/servarr/sonarr'; -import { TmdbTvDetails } from '../../../api/themoviedb/interfaces'; -import Media from '../../../entity/Media'; -import { getSettings, SonarrSettings } from '../../settings'; -import BaseScanner, { +import type { SonarrSeries } from '@server/api/servarr/sonarr'; +import SonarrAPI from '@server/api/servarr/sonarr'; +import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import type { ProcessableSeason, RunnableScanner, 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 & { currentServer: SonarrSettings; diff --git a/server/lib/search.ts b/server/lib/search.ts index c625f512d..be9ee3ae8 100644 --- a/server/lib/search.ts +++ b/server/lib/search.ts @@ -1,5 +1,5 @@ -import TheMovieDb from '../api/themoviedb'; -import { +import TheMovieDb from '@server/api/themoviedb'; +import type { TmdbMovieDetails, TmdbMovieResult, TmdbPersonDetails, @@ -9,13 +9,17 @@ import { TmdbSearchTvResponse, TmdbTvDetails, TmdbTvResult, -} from '../api/themoviedb/interfaces'; +} from '@server/api/themoviedb/interfaces'; import { mapMovieDetailsToResult, mapPersonDetailsToResult, mapTvDetailsToResult, -} from '../models/Search'; -import { isMovie, isMovieDetails, isTvDetails } from '../utils/typeHelpers'; +} from '@server/models/Search'; +import { + isMovie, + isMovieDetails, + isTvDetails, +} from '@server/utils/typeHelpers'; interface SearchProvider { pattern: RegExp; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 53fe864c1..29e2fcf13 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -1,9 +1,9 @@ +import { MediaServerType } from '@server/constants/server'; import { randomUUID } from 'crypto'; import fs from 'fs'; import { merge } from 'lodash'; import path from 'path'; import webpush from 'web-push'; -import { MediaServerType } from '../constants/server'; import { Permission } from './permissions'; export interface Library { @@ -257,6 +257,7 @@ interface JobSettings { export type JobId = | 'plex-recently-added-scan' | 'plex-full-scan' + | 'plex-watchlist-sync' | 'radarr-scan' | 'sonarr-scan' | 'download-sync' @@ -424,6 +425,9 @@ class Settings { 'plex-full-scan': { schedule: '0 0 3 * * *', }, + 'plex-watchlist-sync': { + schedule: '0 */10 * * * *', + }, 'radarr-scan': { schedule: '0 0 4 * * *', }, diff --git a/server/lib/watchlistsync.ts b/server/lib/watchlistsync.ts new file mode 100644 index 000000000..46147f3fc --- /dev/null +++ b/server/lib/watchlistsync.ts @@ -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; diff --git a/server/logger.ts b/server/logger.ts index 4f736e4ab..d5809a0ed 100644 --- a/server/logger.ts +++ b/server/logger.ts @@ -26,7 +26,7 @@ const hformat = winston.format.printf( ); const logger = winston.createLogger({ - level: process.env.LOG_LEVEL || 'debug', + level: process.env.LOG_LEVEL?.toLowerCase() || 'debug', format: winston.format.combine( winston.format.splat(), winston.format.timestamp(), diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 68869222f..326d460d8 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -1,11 +1,14 @@ -import { getRepository } from 'typeorm'; -import { User } from '../entity/User'; -import { Permission, PermissionCheckOptions } from '../lib/permissions'; -import { getSettings } from '../lib/settings'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import type { + Permission, + PermissionCheckOptions, +} from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; export const checkUser: Middleware = async (req, _res, next) => { const settings = getSettings(); - let user: User | undefined; + let user: User | undefined | null; if (req.header('X-API-Key') === settings.main.apiKey) { const userRepository = getRepository(User); diff --git a/server/migration/1603944374840-InitialMigration.ts b/server/migration/1603944374840-InitialMigration.ts index 73640565c..db71471ae 100644 --- a/server/migration/1603944374840-InitialMigration.ts +++ b/server/migration/1603944374840-InitialMigration.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class InitialMigration1603944374840 implements MigrationInterface { name = 'InitialMigration1603944374840'; diff --git a/server/migration/1605085519544-SeasonStatus.ts b/server/migration/1605085519544-SeasonStatus.ts index bcff6f609..059c6bf51 100644 --- a/server/migration/1605085519544-SeasonStatus.ts +++ b/server/migration/1605085519544-SeasonStatus.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class SeasonStatus1605085519544 implements MigrationInterface { name = 'SeasonStatus1605085519544'; diff --git a/server/migration/1606730060700-CascadeMigration.ts b/server/migration/1606730060700-CascadeMigration.ts index 341bc00b3..3b1ae0702 100644 --- a/server/migration/1606730060700-CascadeMigration.ts +++ b/server/migration/1606730060700-CascadeMigration.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class CascadeMigration1606730060700 implements MigrationInterface { name = 'CascadeMigration1606730060700'; diff --git a/server/migration/1607928251245-DropImdbIdConstraint.ts b/server/migration/1607928251245-DropImdbIdConstraint.ts index 97baa861a..f602ea7fa 100644 --- a/server/migration/1607928251245-DropImdbIdConstraint.ts +++ b/server/migration/1607928251245-DropImdbIdConstraint.ts @@ -1,4 +1,5 @@ -import { MigrationInterface, QueryRunner, TableUnique } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; +import { TableUnique } from 'typeorm'; export class DropImdbIdConstraint1607928251245 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1608217312474-AddUserRequestDeleteCascades.ts b/server/migration/1608217312474-AddUserRequestDeleteCascades.ts index e2aa88653..622a2c90e 100644 --- a/server/migration/1608217312474-AddUserRequestDeleteCascades.ts +++ b/server/migration/1608217312474-AddUserRequestDeleteCascades.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserRequestDeleteCascades1608219049304 implements MigrationInterface diff --git a/server/migration/1608477467935-AddLastSeasonChangeMedia.ts b/server/migration/1608477467935-AddLastSeasonChangeMedia.ts index fba7af7f3..e5ab02506 100644 --- a/server/migration/1608477467935-AddLastSeasonChangeMedia.ts +++ b/server/migration/1608477467935-AddLastSeasonChangeMedia.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddLastSeasonChangeMedia1608477467935 implements MigrationInterface diff --git a/server/migration/1608477467936-ForceDropImdbUniqueConstraint.ts b/server/migration/1608477467936-ForceDropImdbUniqueConstraint.ts index 6a109e4d1..d54c450e4 100644 --- a/server/migration/1608477467936-ForceDropImdbUniqueConstraint.ts +++ b/server/migration/1608477467936-ForceDropImdbUniqueConstraint.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class ForceDropImdbUniqueConstraint1608477467935 implements MigrationInterface diff --git a/server/migration/1609236552057-RemoveTmdbIdUniqueConstraint.ts b/server/migration/1609236552057-RemoveTmdbIdUniqueConstraint.ts index 2cd5415e7..500568927 100644 --- a/server/migration/1609236552057-RemoveTmdbIdUniqueConstraint.ts +++ b/server/migration/1609236552057-RemoveTmdbIdUniqueConstraint.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class RemoveTmdbIdUniqueConstraint1609236552057 implements MigrationInterface diff --git a/server/migration/1610070934506-LocalUsers.ts b/server/migration/1610070934506-LocalUsers.ts index 0ece00f4d..88b0ae607 100644 --- a/server/migration/1610070934506-LocalUsers.ts +++ b/server/migration/1610070934506-LocalUsers.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class LocalUsers1610070934506 implements MigrationInterface { name = 'LocalUsers1610070934506'; diff --git a/server/migration/1610370640747-Add4kStatusFields.ts b/server/migration/1610370640747-Add4kStatusFields.ts index a313bf135..5502b9c0f 100644 --- a/server/migration/1610370640747-Add4kStatusFields.ts +++ b/server/migration/1610370640747-Add4kStatusFields.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class Add4kStatusFields1610370640747 implements MigrationInterface { name = 'Add4kStatusFields1610370640747'; diff --git a/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts b/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts index 25e42a74e..d6574d396 100644 --- a/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts +++ b/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddMediaAddedFieldToMedia1610522845513 implements MigrationInterface diff --git a/server/migration/1611508672722-AddDisplayNameToUser.ts b/server/migration/1611508672722-AddDisplayNameToUser.ts index cacea0597..6a36f29a9 100644 --- a/server/migration/1611508672722-AddDisplayNameToUser.ts +++ b/server/migration/1611508672722-AddDisplayNameToUser.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddDisplayNameToUser1611508672722 implements MigrationInterface { name = 'AddDisplayNameToUser1611508672722'; diff --git a/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts b/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts index 355384a05..5a5b65533 100644 --- a/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts +++ b/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class SonarrRadarrSyncServiceFields1611757511674 implements MigrationInterface diff --git a/server/migration/1611801511397-AddRatingKeysToMedia.ts b/server/migration/1611801511397-AddRatingKeysToMedia.ts index f9865c8f5..92ab4d4b4 100644 --- a/server/migration/1611801511397-AddRatingKeysToMedia.ts +++ b/server/migration/1611801511397-AddRatingKeysToMedia.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddRatingKeysToMedia1611801511397 implements MigrationInterface { name = 'AddRatingKeysToMedia1611801511397'; diff --git a/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts b/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts index 7d191d106..55a20a390 100644 --- a/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts +++ b/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddResetPasswordGuidAndExpiryDate1612482778137 implements MigrationInterface diff --git a/server/migration/1612571545781-AddLanguageProfileId.ts b/server/migration/1612571545781-AddLanguageProfileId.ts index fa89d81b7..7694f4e4f 100644 --- a/server/migration/1612571545781-AddLanguageProfileId.ts +++ b/server/migration/1612571545781-AddLanguageProfileId.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddLanguageProfileId1612571545781 implements MigrationInterface { name = 'AddLanguageProfileId1612571545781'; diff --git a/server/migration/1613615266968-CreateUserSettings.ts b/server/migration/1613615266968-CreateUserSettings.ts index 4d4a973e9..fbe85339c 100644 --- a/server/migration/1613615266968-CreateUserSettings.ts +++ b/server/migration/1613615266968-CreateUserSettings.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class CreateUserSettings1613615266968 implements MigrationInterface { name = 'CreateUserSettings1613615266968'; diff --git a/server/migration/1613955393450-UpdateUserSettingsRegions.ts b/server/migration/1613955393450-UpdateUserSettingsRegions.ts index d33df4eef..69060a0cb 100644 --- a/server/migration/1613955393450-UpdateUserSettingsRegions.ts +++ b/server/migration/1613955393450-UpdateUserSettingsRegions.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class UpdateUserSettingsRegions1613955393450 implements MigrationInterface diff --git a/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts b/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts index 5e480d481..6e2598ab4 100644 --- a/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts +++ b/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddTelegramSettingsToUserSettings1614334195680 implements MigrationInterface diff --git a/server/migration/1615333940450-AddPGPToUserSettings.ts b/server/migration/1615333940450-AddPGPToUserSettings.ts index b88e0dcaa..6940d4adc 100644 --- a/server/migration/1615333940450-AddPGPToUserSettings.ts +++ b/server/migration/1615333940450-AddPGPToUserSettings.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddPGPToUserSettings1615333940450 implements MigrationInterface { name = 'AddPGPToUserSettings1615333940450'; diff --git a/server/migration/1616576677254-AddUserQuotaFields.ts b/server/migration/1616576677254-AddUserQuotaFields.ts index 632926900..62b39d65a 100644 --- a/server/migration/1616576677254-AddUserQuotaFields.ts +++ b/server/migration/1616576677254-AddUserQuotaFields.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserQuotaFields1616576677254 implements MigrationInterface { name = 'AddUserQuotaFields1616576677254'; diff --git a/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts b/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts index d498a8b17..9e6761825 100644 --- a/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts +++ b/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class CreateTagsFieldonMediaRequest1617624225464 implements MigrationInterface diff --git a/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts b/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts index 79cd061b8..9dd9288e6 100644 --- a/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts +++ b/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserSettingsNotificationAgentsField1617730837489 implements MigrationInterface diff --git a/server/migration/1618912653565-CreateUserPushSubscriptions.ts b/server/migration/1618912653565-CreateUserPushSubscriptions.ts index 539221d17..970705990 100644 --- a/server/migration/1618912653565-CreateUserPushSubscriptions.ts +++ b/server/migration/1618912653565-CreateUserPushSubscriptions.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class CreateUserPushSubscriptions1618912653565 implements MigrationInterface diff --git a/server/migration/1619239659754-AddUserSettingsLocale.ts b/server/migration/1619239659754-AddUserSettingsLocale.ts index 9842bca71..ba182b03a 100644 --- a/server/migration/1619239659754-AddUserSettingsLocale.ts +++ b/server/migration/1619239659754-AddUserSettingsLocale.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserSettingsLocale1619239659754 implements MigrationInterface { name = 'AddUserSettingsLocale1619239659754'; diff --git a/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts b/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts index cccdae2fa..50de959b2 100644 --- a/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts +++ b/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserSettingsNotificationTypes1619339817343 implements MigrationInterface diff --git a/server/migration/1634904083966-AddIssues.ts b/server/migration/1634904083966-AddIssues.ts index 0c6116f9d..ebcf8d89d 100644 --- a/server/migration/1634904083966-AddIssues.ts +++ b/server/migration/1634904083966-AddIssues.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddIssues1634904083966 implements MigrationInterface { name = 'AddIssues1634904083966'; diff --git a/server/migration/1635079863457-AddPushbulletPushoverUserSettings.ts b/server/migration/1635079863457-AddPushbulletPushoverUserSettings.ts index 8934866fa..c29cef6d0 100644 --- a/server/migration/1635079863457-AddPushbulletPushoverUserSettings.ts +++ b/server/migration/1635079863457-AddPushbulletPushoverUserSettings.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddPushbulletPushoverUserSettings1635079863457 implements MigrationInterface diff --git a/server/migration/1660632269368-AddWatchlistSyncUserSetting.ts b/server/migration/1660632269368-AddWatchlistSyncUserSetting.ts new file mode 100644 index 000000000..c0d0e947f --- /dev/null +++ b/server/migration/1660632269368-AddWatchlistSyncUserSetting.ts @@ -0,0 +1,33 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddWatchlistSyncUserSetting1660632269368 + implements MigrationInterface +{ + name = 'AddWatchlistSyncUserSetting1660632269368'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey" FROM "user_settings"` + ); + await queryRunner.query(`DROP TABLE "user_settings"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` + ); + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + } +} diff --git a/server/migration/1660714479373-AddMediaRequestIsAutoRequestedField.ts b/server/migration/1660714479373-AddMediaRequestIsAutoRequestedField.ts new file mode 100644 index 000000000..8580bb4ed --- /dev/null +++ b/server/migration/1660714479373-AddMediaRequestIsAutoRequestedField.ts @@ -0,0 +1,33 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddMediaRequestIsAutoRequestedField1660714479373 + implements MigrationInterface +{ + name = 'AddMediaRequestIsAutoRequestedField1660714479373'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT (0), CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags" FROM "media_request"` + ); + await queryRunner.query(`DROP TABLE "media_request"`); + await queryRunner.query( + `ALTER TABLE "temporary_media_request" RENAME TO "media_request"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "media_request" RENAME TO "temporary_media_request"` + ); + await queryRunner.query( + `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags" FROM "temporary_media_request"` + ); + await queryRunner.query(`DROP TABLE "temporary_media_request"`); + } +} diff --git a/server/models/Collection.ts b/server/models/Collection.ts index 9cc4f3788..20a3c7158 100644 --- a/server/models/Collection.ts +++ b/server/models/Collection.ts @@ -1,8 +1,9 @@ +import type { TmdbCollection } from '@server/api/themoviedb/interfaces'; +import { MediaType } from '@server/constants/media'; +import type Media from '@server/entity/Media'; import { sortBy } from 'lodash'; -import type { TmdbCollection } from '../api/themoviedb/interfaces'; -import { MediaType } from '../constants/media'; -import Media from '../entity/Media'; -import { mapMovieResult, MovieResult } from './Search'; +import type { MovieResult } from './Search'; +import { mapMovieResult } from './Search'; export interface Collection { id: number; diff --git a/server/models/Movie.ts b/server/models/Movie.ts index ac19ce7e0..a216b7437 100644 --- a/server/models/Movie.ts +++ b/server/models/Movie.ts @@ -2,20 +2,22 @@ import type { TmdbMovieDetails, TmdbMovieReleaseResult, TmdbProductionCompany, -} from '../api/themoviedb/interfaces'; -import Media from '../entity/Media'; -import { +} from '@server/api/themoviedb/interfaces'; +import type Media from '@server/entity/Media'; +import type { Cast, Crew, ExternalIds, Genre, + ProductionCompany, + WatchProviders, +} from './common'; +import { mapCast, mapCrew, mapExternalIds, mapVideos, mapWatchProviders, - ProductionCompany, - WatchProviders, } from './common'; export interface Video { diff --git a/server/models/Person.ts b/server/models/Person.ts index 087ab1c7b..998585ee8 100644 --- a/server/models/Person.ts +++ b/server/models/Person.ts @@ -2,8 +2,8 @@ import type { TmdbPersonCreditCast, TmdbPersonCreditCrew, TmdbPersonDetails, -} from '../api/themoviedb/interfaces'; -import Media from '../entity/Media'; +} from '@server/api/themoviedb/interfaces'; +import type Media from '@server/entity/Media'; export interface PersonDetails { id: number; diff --git a/server/models/Search.ts b/server/models/Search.ts index 73427a378..6ab696fe3 100644 --- a/server/models/Search.ts +++ b/server/models/Search.ts @@ -5,9 +5,9 @@ import type { TmdbPersonResult, TmdbTvDetails, TmdbTvResult, -} from '../api/themoviedb/interfaces'; -import { MediaType as MainMediaType } from '../constants/media'; -import Media from '../entity/Media'; +} from '@server/api/themoviedb/interfaces'; +import { MediaType as MainMediaType } from '@server/constants/media'; +import type Media from '@server/entity/Media'; export type MediaType = 'tv' | 'movie' | 'person'; diff --git a/server/models/Tv.ts b/server/models/Tv.ts index b596b1d2b..7f809cbf4 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -5,24 +5,26 @@ import type { TmdbTvEpisodeResult, TmdbTvRatingResult, TmdbTvSeasonResult, -} from '../api/themoviedb/interfaces'; -import type Media from '../entity/Media'; -import { +} from '@server/api/themoviedb/interfaces'; +import type Media from '@server/entity/Media'; +import type { Cast, Crew, ExternalIds, Genre, Keyword, + ProductionCompany, + TvNetwork, + WatchProviders, +} from './common'; +import { mapAggregateCast, mapCrew, mapExternalIds, mapVideos, mapWatchProviders, - ProductionCompany, - TvNetwork, - WatchProviders, } from './common'; -import { Video } from './Movie'; +import type { Video } from './Movie'; interface Episode { id: number; diff --git a/server/models/common.ts b/server/models/common.ts index 49e2305cb..30b40d98c 100644 --- a/server/models/common.ts +++ b/server/models/common.ts @@ -7,8 +7,8 @@ import type { TmdbVideoResult, TmdbWatchProviderDetails, TmdbWatchProviders, -} from '../api/themoviedb/interfaces'; -import { Video } from '../models/Movie'; +} from '@server/api/themoviedb/interfaces'; +import type { Video } from '@server/models/Movie'; export interface ProductionCompany { id: number; diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 81ba519fc..4b22943e1 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -1,16 +1,16 @@ -import { Router } from 'express'; -import { getRepository } from 'typeorm'; -import JellyfinAPI from '../api/jellyfin'; -import PlexTvAPI from '../api/plextv'; -import { MediaServerType } from '../constants/server'; -import { UserType } from '../constants/user'; -import { User } from '../entity/User'; -import { startJobs } from '../job/schedule'; -import { Permission } from '../lib/permissions'; -import { getSettings } from '../lib/settings'; -import logger from '../logger'; -import { isAuthenticated } from '../middleware/auth'; +import JellyfinAPI from '@server/api/jellyfin'; +import PlexTvAPI from '@server/api/plextv'; +import { MediaServerType } from '@server/constants/server'; +import { UserType } from '@server/constants/user'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import { startJobs } from '@server/job/schedule'; +import { Permission } from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { isAuthenticated } from '@server/middleware/auth'; import * as EmailValidator from 'email-validator'; +import { Router } from 'express'; const authRoutes = Router(); @@ -89,8 +89,8 @@ authRoutes.post('/plex', async (req, res, next) => { await userRepository.save(user); } else { const mainUser = await userRepository.findOneOrFail({ - select: ['id', 'plexToken', 'plexId'], - order: { id: 'ASC' }, + select: { id: true, plexToken: true, plexId: true }, + where: { id: 1 }, }); const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); @@ -424,8 +424,8 @@ authRoutes.post('/local', async (req, res, next) => { } const mainUser = await userRepository.findOneOrFail({ - select: ['id', 'plexToken', 'plexId'], - order: { id: 'ASC' }, + select: { id: true, plexToken: true, plexId: true }, + where: { id: 1 }, }); const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); diff --git a/server/routes/collection.ts b/server/routes/collection.ts index aa8948736..d58b0357d 100644 --- a/server/routes/collection.ts +++ b/server/routes/collection.ts @@ -1,8 +1,8 @@ +import TheMovieDb from '@server/api/themoviedb'; +import Media from '@server/entity/Media'; +import logger from '@server/logger'; +import { mapCollection } from '@server/models/Collection'; import { Router } from 'express'; -import TheMovieDb from '../api/themoviedb'; -import Media from '../entity/Media'; -import logger from '../logger'; -import { mapCollection } from '../models/Collection'; const collectionRoutes = Router(); diff --git a/server/routes/discover.ts b/server/routes/discover.ts index ea78bf03d..b39a83325 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -1,16 +1,25 @@ +import PlexTvAPI from '@server/api/plextv'; +import TheMovieDb from '@server/api/themoviedb'; +import { MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { User } from '@server/entity/User'; +import type { + GenreSliderItem, + WatchlistResponse, +} from '@server/interfaces/api/discoverInterfaces'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { mapProductionCompany } from '@server/models/Movie'; +import { + mapMovieResult, + mapPersonResult, + mapTvResult, +} from '@server/models/Search'; +import { mapNetwork } from '@server/models/Tv'; +import { isMovie, isPerson } from '@server/utils/typeHelpers'; import { Router } from 'express'; import { sortBy } from 'lodash'; -import TheMovieDb from '../api/themoviedb'; -import { MediaType } from '../constants/media'; -import Media from '../entity/Media'; -import { User } from '../entity/User'; -import { GenreSliderItem } from '../interfaces/api/discoverInterfaces'; -import { getSettings } from '../lib/settings'; -import logger from '../logger'; -import { mapProductionCompany } from '../models/Movie'; -import { mapMovieResult, mapPersonResult, mapTvResult } from '../models/Search'; -import { mapNetwork } from '../models/Tv'; -import { isMovie, isPerson } from '../utils/typeHelpers'; export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => { const settings = getSettings(); @@ -704,4 +713,45 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>( } ); +discoverRoutes.get<{ page?: number }, WatchlistResponse>( + '/watchlist', + async (req, res) => { + const userRepository = getRepository(User); + const itemsPerPage = 20; + const page = req.params.page ?? 1; + const offset = (page - 1) * itemsPerPage; + + const activeUser = await userRepository.findOne({ + where: { id: req.user?.id }, + select: ['id', 'plexToken'], + }); + + if (!activeUser?.plexToken) { + // We will just return an empty array if the user has no Plex token + return res.json({ + page: 1, + totalPages: 1, + totalResults: 0, + results: [], + }); + } + + const plexTV = new PlexTvAPI(activeUser.plexToken); + + const watchlist = await plexTV.getWatchlist({ offset }); + + return res.json({ + page, + totalPages: Math.ceil(watchlist.size / itemsPerPage), + totalResults: watchlist.size, + results: watchlist.items.map((item) => ({ + ratingKey: item.ratingKey, + title: item.title, + mediaType: item.type === 'show' ? 'tv' : 'movie', + tmdbId: item.tmdbId, + })), + }); + } +); + export default discoverRoutes; diff --git a/server/routes/index.ts b/server/routes/index.ts index e28666385..9561e171b 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -1,17 +1,22 @@ +import GithubAPI from '@server/api/github'; +import TheMovieDb from '@server/api/themoviedb'; +import type { + TmdbMovieResult, + TmdbTvResult, +} from '@server/api/themoviedb/interfaces'; +import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces'; +import { Permission } from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { checkUser, isAuthenticated } from '@server/middleware/auth'; +import { mapProductionCompany } from '@server/models/Movie'; +import { mapNetwork } from '@server/models/Tv'; +import settingsRoutes from '@server/routes/settings'; +import { appDataPath, appDataStatus } from '@server/utils/appDataVolume'; +import { getAppVersion, getCommitTag } from '@server/utils/appVersion'; +import restartFlag from '@server/utils/restartFlag'; +import { isPerson } from '@server/utils/typeHelpers'; import { Router } from 'express'; -import GithubAPI from '../api/github'; -import TheMovieDb from '../api/themoviedb'; -import { TmdbMovieResult, TmdbTvResult } from '../api/themoviedb/interfaces'; -import { StatusResponse } from '../interfaces/api/settingsInterfaces'; -import { Permission } from '../lib/permissions'; -import { getSettings } from '../lib/settings'; -import logger from '../logger'; -import { checkUser, isAuthenticated } from '../middleware/auth'; -import { mapProductionCompany } from '../models/Movie'; -import { mapNetwork } from '../models/Tv'; -import { appDataPath, appDataStatus } from '../utils/appDataVolume'; -import { getAppVersion, getCommitTag } from '../utils/appVersion'; -import { isPerson } from '../utils/typeHelpers'; import authRoutes from './auth'; import collectionRoutes from './collection'; import discoverRoutes, { createTmdbWithRegionLanguage } from './discover'; @@ -23,7 +28,6 @@ import personRoutes from './person'; import requestRoutes from './request'; import searchRoutes from './search'; import serviceRoutes from './service'; -import settingsRoutes from './settings'; import tvRoutes from './tv'; import user from './user'; @@ -75,6 +79,7 @@ router.get('/status', async (req, res) => { commitTag: getCommitTag(), updateAvailable, commitsBehind, + restartRequired: restartFlag.isSet(), }); }); @@ -97,11 +102,7 @@ router.get('/settings/public', async (req, res) => { return res.status(200).json(settings.fullPublicSettings); } }); -router.use( - '/settings', - isAuthenticated(Permission.MANAGE_SETTINGS), - settingsRoutes -); +router.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes); router.use('/search', isAuthenticated(), searchRoutes); router.use('/discover', isAuthenticated(), discoverRoutes); router.use('/request', isAuthenticated(), requestRoutes); diff --git a/server/routes/issue.ts b/server/routes/issue.ts index 07cf3277d..6349bb74a 100644 --- a/server/routes/issue.ts +++ b/server/routes/issue.ts @@ -1,13 +1,13 @@ +import { IssueStatus, IssueType } from '@server/constants/issue'; +import { getRepository } from '@server/datasource'; +import Issue from '@server/entity/Issue'; +import IssueComment from '@server/entity/IssueComment'; +import Media from '@server/entity/Media'; +import type { IssueResultsResponse } from '@server/interfaces/api/issueInterfaces'; +import { Permission } from '@server/lib/permissions'; +import logger from '@server/logger'; +import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; -import { getRepository } from 'typeorm'; -import { IssueStatus, IssueType } from '../constants/issue'; -import Issue from '../entity/Issue'; -import IssueComment from '../entity/IssueComment'; -import Media from '../entity/Media'; -import { IssueResultsResponse } from '../interfaces/api/issueInterfaces'; -import { Permission } from '../lib/permissions'; -import logger from '../logger'; -import { isAuthenticated } from '../middleware/auth'; const issueRoutes = Router(); @@ -365,7 +365,7 @@ issueRoutes.delete( try { const issue = await issueRepository.findOneOrFail({ where: { id: Number(req.params.issueId) }, - relations: ['createdBy'], + relations: { createdBy: true }, }); if ( diff --git a/server/routes/issueComment.ts b/server/routes/issueComment.ts index c54bce5b6..85e41aaaf 100644 --- a/server/routes/issueComment.ts +++ b/server/routes/issueComment.ts @@ -1,9 +1,9 @@ +import { getRepository } from '@server/datasource'; +import IssueComment from '@server/entity/IssueComment'; +import { Permission } from '@server/lib/permissions'; +import logger from '@server/logger'; +import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; -import { getRepository } from 'typeorm'; -import IssueComment from '../entity/IssueComment'; -import { Permission } from '../lib/permissions'; -import logger from '../logger'; -import { isAuthenticated } from '../middleware/auth'; const issueCommentRoutes = Router(); diff --git a/server/routes/media.ts b/server/routes/media.ts index 429b2010f..8f93116c0 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -1,17 +1,19 @@ -import { Router } from 'express'; -import { FindOneOptions, FindOperator, getRepository, In } from 'typeorm'; -import TautulliAPI from '../api/tautulli'; -import { MediaStatus, MediaType } from '../constants/media'; -import Media from '../entity/Media'; -import { User } from '../entity/User'; -import { +import TautulliAPI from '@server/api/tautulli'; +import { MediaStatus, MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { User } from '@server/entity/User'; +import type { MediaResultsResponse, MediaWatchDataResponse, -} from '../interfaces/api/mediaInterfaces'; -import { Permission } from '../lib/permissions'; -import { getSettings } from '../lib/settings'; -import logger from '../logger'; -import { isAuthenticated } from '../middleware/auth'; +} from '@server/interfaces/api/mediaInterfaces'; +import { Permission } from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { isAuthenticated } from '@server/middleware/auth'; +import { Router } from 'express'; +import type { FindOneOptions } from 'typeorm'; +import { In } from 'typeorm'; const mediaRoutes = Router(); @@ -21,8 +23,7 @@ mediaRoutes.get('/', async (req, res, next) => { const pageSize = req.query.take ? Number(req.query.take) : 20; const skip = req.query.skip ? Number(req.query.skip) : 0; - let statusFilter: MediaStatus | FindOperator | undefined = - undefined; + let statusFilter = undefined; switch (req.query.filter) { case 'available': @@ -66,7 +67,7 @@ mediaRoutes.get('/', async (req, res, next) => { try { const [media, mediaCount] = await mediaRepository.findAndCount({ order: sortFilter, - where: { + where: statusFilter && { status: statusFilter, }, take: pageSize, @@ -151,7 +152,7 @@ mediaRoutes.delete( const mediaRepository = getRepository(Media); const media = await mediaRepository.findOneOrFail({ - where: { id: req.params.id }, + where: { id: Number(req.params.id) }, }); await mediaRepository.remove(media); diff --git a/server/routes/movie.ts b/server/routes/movie.ts index 98474c78e..f11cead8c 100644 --- a/server/routes/movie.ts +++ b/server/routes/movie.ts @@ -1,11 +1,11 @@ +import RottenTomatoes from '@server/api/rottentomatoes'; +import TheMovieDb from '@server/api/themoviedb'; +import { MediaType } from '@server/constants/media'; +import Media from '@server/entity/Media'; +import logger from '@server/logger'; +import { mapMovieDetails } from '@server/models/Movie'; +import { mapMovieResult } from '@server/models/Search'; import { Router } from 'express'; -import RottenTomatoes from '../api/rottentomatoes'; -import TheMovieDb from '../api/themoviedb'; -import { MediaType } from '../constants/media'; -import Media from '../entity/Media'; -import logger from '../logger'; -import { mapMovieDetails } from '../models/Movie'; -import { mapMovieResult } from '../models/Search'; const movieRoutes = Router(); diff --git a/server/routes/person.ts b/server/routes/person.ts index 5093ae46c..7f5d62236 100644 --- a/server/routes/person.ts +++ b/server/routes/person.ts @@ -1,12 +1,12 @@ -import { Router } from 'express'; -import TheMovieDb from '../api/themoviedb'; -import Media from '../entity/Media'; -import logger from '../logger'; +import TheMovieDb from '@server/api/themoviedb'; +import Media from '@server/entity/Media'; +import logger from '@server/logger'; import { mapCastCredits, mapCrewCredits, mapPersonDetails, -} from '../models/Person'; +} from '@server/models/Person'; +import { Router } from 'express'; const personRoutes = Router(); diff --git a/server/routes/request.ts b/server/routes/request.ts index cd269f4ef..9c9d96a82 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -1,15 +1,27 @@ +import { + MediaRequestStatus, + 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 SeasonRequest from '@server/entity/SeasonRequest'; +import { User } from '@server/entity/User'; +import type { + MediaRequestBody, + RequestResultsResponse, +} from '@server/interfaces/api/requestInterfaces'; +import { Permission } from '@server/lib/permissions'; +import logger from '@server/logger'; +import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; -import { getRepository } from 'typeorm'; -import TheMovieDb from '../api/themoviedb'; -import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media'; -import Media from '../entity/Media'; -import { MediaRequest } from '../entity/MediaRequest'; -import SeasonRequest from '../entity/SeasonRequest'; -import { User } from '../entity/User'; -import { RequestResultsResponse } from '../interfaces/api/requestInterfaces'; -import { Permission } from '../lib/permissions'; -import logger from '../logger'; -import { isAuthenticated } from '../middleware/auth'; const requestRoutes = Router(); @@ -40,11 +52,15 @@ requestRoutes.get, RequestResultsResponse>( MediaRequestStatus.APPROVED, ]; break; + case 'failed': + statusFilter = [MediaRequestStatus.FAILED]; + break; default: statusFilter = [ MediaRequestStatus.PENDING, MediaRequestStatus.APPROVED, MediaRequestStatus.DECLINED, + MediaRequestStatus.FAILED, ]; } @@ -142,302 +158,38 @@ requestRoutes.get, RequestResultsResponse>( } ); -requestRoutes.post('/', async (req, res, next) => { - const tmdb = new TheMovieDb(); - const mediaRepository = getRepository(Media); - const requestRepository = getRepository(MediaRequest); - const userRepository = getRepository(User); - - try { - let requestUser = req.user; - - if ( - req.body.userId && - !req.user?.hasPermission([ - Permission.MANAGE_USERS, - Permission.MANAGE_REQUESTS, - ]) - ) { - return next({ - status: 403, - message: 'You do not have permission to modify the request user.', - }); - } else if (req.body.userId) { - requestUser = await userRepository.findOneOrFail({ - where: { id: req.body.userId }, - }); - } - - if (!requestUser) { - return next({ - status: 500, - message: 'User missing from request context.', - }); - } - - if ( - req.body.mediaType === MediaType.MOVIE && - !req.user?.hasPermission( - req.body.is4k - ? [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE] - : [Permission.REQUEST, Permission.REQUEST_MOVIE], - { - type: 'or', - } - ) - ) { - return next({ - status: 403, - message: `You do not have permission to make ${ - req.body.is4k ? '4K ' : '' - }movie requests.`, - }); - } else if ( - req.body.mediaType === MediaType.TV && - !req.user?.hasPermission( - req.body.is4k - ? [Permission.REQUEST_4K, Permission.REQUEST_4K_TV] - : [Permission.REQUEST, Permission.REQUEST_TV], - { - type: 'or', - } - ) - ) { - return next({ - status: 403, - message: `You do not have permission to make ${ - req.body.is4k ? '4K ' : '' - }series requests.`, - }); - } - - const quotas = await requestUser.getQuota(); - - if (req.body.mediaType === MediaType.MOVIE && quotas.movie.restricted) { - return next({ - status: 403, - message: 'Movie Quota Exceeded', - }); - } else if (req.body.mediaType === MediaType.TV && quotas.tv.restricted) { - return next({ - status: 403, - message: 'Series Quota Exceeded', - }); - } - - const tmdbMedia = - req.body.mediaType === MediaType.MOVIE - ? await tmdb.getMovie({ movieId: req.body.mediaId }) - : await tmdb.getTvShow({ tvId: req.body.mediaId }); - - let media = await mediaRepository.findOne({ - where: { tmdbId: req.body.mediaId, mediaType: req.body.mediaType }, - relations: ['requests'], - }); - - if (!media) { - media = new Media({ - tmdbId: tmdbMedia.id, - tvdbId: req.body.tvdbId ?? tmdbMedia.external_ids.tvdb_id, - status: !req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, - status4k: req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, - mediaType: req.body.mediaType, - }); - } else { - if (media.status === MediaStatus.UNKNOWN && !req.body.is4k) { - media.status = MediaStatus.PENDING; - } - - if (media.status4k === MediaStatus.UNKNOWN && req.body.is4k) { - media.status4k = MediaStatus.PENDING; - } - } - - if (req.body.mediaType === MediaType.MOVIE) { - const existing = await requestRepository - .createQueryBuilder('request') - .leftJoin('request.media', 'media') - .where('request.is4k = :is4k', { is4k: req.body.is4k }) - .andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id }) - .andWhere('media.mediaType = :mediaType', { - mediaType: MediaType.MOVIE, - }) - .andWhere('request.status != :requestStatus', { - requestStatus: MediaRequestStatus.DECLINED, - }) - .getOne(); - - if (existing) { - logger.warn('Duplicate request for media blocked', { - tmdbId: tmdbMedia.id, - mediaType: req.body.mediaType, - is4k: req.body.is4k, - label: 'Media Request', - }); +requestRoutes.post( + '/', + async (req, res, next) => { + try { + if (!req.user) { return next({ - status: 409, - message: 'Request for this media already exists.', + status: 401, + message: 'You must be logged in to request media.', }); } + const request = await MediaRequest.request(req.body, req.user); - 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: req.user?.hasPermission( - [ - req.body.is4k - ? Permission.AUTO_APPROVE_4K - : Permission.AUTO_APPROVE, - req.body.is4k - ? Permission.AUTO_APPROVE_4K_MOVIE - : Permission.AUTO_APPROVE_MOVIE, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } - ) - ? MediaRequestStatus.APPROVED - : MediaRequestStatus.PENDING, - modifiedBy: req.user?.hasPermission( - [ - req.body.is4k - ? Permission.AUTO_APPROVE_4K - : Permission.AUTO_APPROVE, - req.body.is4k - ? Permission.AUTO_APPROVE_4K_MOVIE - : Permission.AUTO_APPROVE_MOVIE, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } - ) - ? req.user - : undefined, - is4k: req.body.is4k, - serverId: req.body.serverId, - profileId: req.body.profileId, - rootFolder: req.body.rootFolder, - tags: req.body.tags, - }); - - await requestRepository.save(request); return res.status(201).json(request); - } else if (req.body.mediaType === MediaType.TV) { - const requestedSeasons = req.body.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 === req.body.is4k && - request.status !== MediaRequestStatus.DECLINED - ) - .reduce((seasons, request) => { - const combinedSeasons = request.seasons.map( - (season) => season.seasonNumber - ); - - return [...seasons, ...combinedSeasons]; - }, [] as number[]); + } catch (error) { + if (!(error instanceof Error)) { + return; } - const finalSeasons = requestedSeasons.filter( - (rs) => !existingSeasons.includes(rs) - ); - - if (finalSeasons.length === 0) { - return next({ - status: 202, - message: 'No seasons available to request', - }); - } else if ( - quotas.tv.limit && - finalSeasons.length > (quotas.tv.remaining ?? 0) - ) { - return next({ - status: 403, - message: 'Series Quota Exceeded', - }); + switch (error.constructor) { + case RequestPermissionError: + case QuotaRestrictedError: + return next({ status: 403, message: error.message }); + case DuplicateMediaRequestError: + return next({ status: 409, message: error.message }); + case NoSeasonsAvailableError: + return next({ status: 202, message: error.message }); + default: + return next({ status: 500, message: error.message }); } - - 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: req.user?.hasPermission( - [ - req.body.is4k - ? Permission.AUTO_APPROVE_4K - : Permission.AUTO_APPROVE, - req.body.is4k - ? Permission.AUTO_APPROVE_4K_TV - : Permission.AUTO_APPROVE_TV, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } - ) - ? MediaRequestStatus.APPROVED - : MediaRequestStatus.PENDING, - modifiedBy: req.user?.hasPermission( - [ - req.body.is4k - ? Permission.AUTO_APPROVE_4K - : Permission.AUTO_APPROVE, - req.body.is4k - ? Permission.AUTO_APPROVE_4K_TV - : Permission.AUTO_APPROVE_TV, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } - ) - ? req.user - : undefined, - is4k: req.body.is4k, - serverId: req.body.serverId, - profileId: req.body.profileId, - rootFolder: req.body.rootFolder, - languageProfileId: req.body.languageProfileId, - tags: req.body.tags, - seasons: finalSeasons.map( - (sn) => - new SeasonRequest({ - seasonNumber: sn, - status: req.user?.hasPermission( - [ - req.body.is4k - ? Permission.AUTO_APPROVE_4K - : Permission.AUTO_APPROVE, - req.body.is4k - ? Permission.AUTO_APPROVE_4K_TV - : Permission.AUTO_APPROVE_TV, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } - ) - ? MediaRequestStatus.APPROVED - : MediaRequestStatus.PENDING, - }) - ), - }); - - await requestRepository.save(request); - return res.status(201).json(request); } - - next({ status: 500, message: 'Invalid media type' }); - } catch (e) { - next({ status: 500, message: e.message }); } -}); +); requestRoutes.get('/count', async (_req, res, next) => { const requestRepository = getRepository(MediaRequest); @@ -528,7 +280,7 @@ requestRoutes.get('/:requestId', async (req, res, next) => { try { const request = await requestRepository.findOneOrFail({ where: { id: Number(req.params.requestId) }, - relations: ['requestedBy', 'modifiedBy'], + relations: { requestedBy: true, modifiedBy: true }, }); if ( @@ -560,9 +312,11 @@ requestRoutes.put<{ requestId: string }>( const requestRepository = getRepository(MediaRequest); const userRepository = getRepository(User); try { - const request = await requestRepository.findOne( - Number(req.params.requestId) - ); + const request = await requestRepository.findOne({ + where: { + id: Number(req.params.requestId), + }, + }); if (!request) { return next({ status: 404, message: 'Request not found.' }); @@ -628,7 +382,7 @@ requestRoutes.put<{ requestId: string }>( // Get existing media so we can work with all the requests const media = await mediaRepository.findOneOrFail({ where: { tmdbId: request.media.tmdbId, mediaType: MediaType.TV }, - relations: ['requests'], + relations: { requests: true }, }); // Get all requested seasons that are not part of this request we are editing @@ -698,7 +452,7 @@ requestRoutes.delete('/:requestId', async (req, res, next) => { try { const request = await requestRepository.findOneOrFail({ where: { id: Number(req.params.requestId) }, - relations: ['requestedBy', 'modifiedBy'], + relations: { requestedBy: true, modifiedBy: true }, }); if ( @@ -735,7 +489,7 @@ requestRoutes.post<{ try { const request = await requestRepository.findOneOrFail({ where: { id: Number(req.params.requestId) }, - relations: ['requestedBy', 'modifiedBy'], + relations: { requestedBy: true, modifiedBy: true }, }); await request.updateParentStatus(); @@ -763,7 +517,7 @@ requestRoutes.post<{ try { const request = await requestRepository.findOneOrFail({ where: { id: Number(req.params.requestId) }, - relations: ['requestedBy', 'modifiedBy'], + relations: { requestedBy: true, modifiedBy: true }, }); let newStatus: MediaRequestStatus; diff --git a/server/routes/search.ts b/server/routes/search.ts index 3f26a3939..1152bce31 100644 --- a/server/routes/search.ts +++ b/server/routes/search.ts @@ -1,10 +1,10 @@ +import TheMovieDb from '@server/api/themoviedb'; +import type { TmdbSearchMultiResponse } from '@server/api/themoviedb/interfaces'; +import Media from '@server/entity/Media'; +import { findSearchProvider } from '@server/lib/search'; +import logger from '@server/logger'; +import { mapSearchResults } from '@server/models/Search'; import { Router } from 'express'; -import TheMovieDb from '../api/themoviedb'; -import { TmdbSearchMultiResponse } from '../api/themoviedb/interfaces'; -import Media from '../entity/Media'; -import { findSearchProvider } from '../lib/search'; -import logger from '../logger'; -import { mapSearchResults } from '../models/Search'; const searchRoutes = Router(); diff --git a/server/routes/service.ts b/server/routes/service.ts index 862ab3748..b77d58c9d 100644 --- a/server/routes/service.ts +++ b/server/routes/service.ts @@ -1,13 +1,13 @@ -import { Router } from 'express'; -import RadarrAPI from '../api/servarr/radarr'; -import SonarrAPI from '../api/servarr/sonarr'; -import TheMovieDb from '../api/themoviedb'; -import { +import RadarrAPI from '@server/api/servarr/radarr'; +import SonarrAPI from '@server/api/servarr/sonarr'; +import TheMovieDb from '@server/api/themoviedb'; +import type { ServiceCommonServer, ServiceCommonServerWithDetails, -} from '../interfaces/api/serviceInterfaces'; -import { getSettings } from '../lib/settings'; -import logger from '../logger'; +} from '@server/interfaces/api/serviceInterfaces'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { Router } from 'express'; const serviceRoutes = Router(); @@ -191,7 +191,7 @@ serviceRoutes.get<{ tmdbId: string }>( try { const tv = await tmdb.getTvShow({ tvId: Number(req.params.tmdbId), - language: req.locale ?? (req.query.language as string), + language: 'en', }); const response = await sonarr.getSeriesByTitle(tv.name); diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 7ebff7605..fc83839b8 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -1,3 +1,29 @@ +import JellyfinAPI from '@server/api/jellyfin'; +import PlexAPI from '@server/api/plexapi'; +import PlexTvAPI from '@server/api/plextv'; +import TautulliAPI from '@server/api/tautulli'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { MediaRequest } from '@server/entity/MediaRequest'; +import { User } from '@server/entity/User'; +import type { PlexConnection } from '@server/interfaces/api/plexInterfaces'; +import type { + LogMessage, + LogsResultsResponse, + SettingsAboutResponse, +} from '@server/interfaces/api/settingsInterfaces'; +import { jobJellyfinFullSync } from '@server/job/jellyfinsync'; +import { scheduledJobs } from '@server/job/schedule'; +import type { AvailableCacheIds } from '@server/lib/cache'; +import cacheManager from '@server/lib/cache'; +import { Permission } from '@server/lib/permissions'; +import { plexFullScanner } from '@server/lib/scanners/plex'; +import type { Library, MainSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { isAuthenticated } from '@server/middleware/auth'; +import { appDataPath } from '@server/utils/appDataVolume'; +import { getAppVersion } from '@server/utils/appVersion'; import { Router } from 'express'; import rateLimit from 'express-rate-limit'; import fs from 'fs'; @@ -5,31 +31,7 @@ import { merge, omit, set, sortBy } from 'lodash'; import { rescheduleJob } from 'node-schedule'; import path from 'path'; import semver from 'semver'; -import { getRepository } from 'typeorm'; import { URL } from 'url'; -import JellyfinAPI from '../../api/jellyfin'; -import PlexAPI from '../../api/plexapi'; -import PlexTvAPI from '../../api/plextv'; -import TautulliAPI from '../../api/tautulli'; -import Media from '../../entity/Media'; -import { MediaRequest } from '../../entity/MediaRequest'; -import { User } from '../../entity/User'; -import { PlexConnection } from '../../interfaces/api/plexInterfaces'; -import { - LogMessage, - LogsResultsResponse, - SettingsAboutResponse, -} from '../../interfaces/api/settingsInterfaces'; -import { jobJellyfinFullSync } from '../../job/jellyfinsync'; -import { scheduledJobs } from '../../job/schedule'; -import cacheManager, { AvailableCacheIds } from '../../lib/cache'; -import { Permission } from '../../lib/permissions'; -import { plexFullScanner } from '../../lib/scanners/plex'; -import { getSettings, Library, MainSettings } from '../../lib/settings'; -import logger from '../../logger'; -import { isAuthenticated } from '../../middleware/auth'; -import { appDataPath } from '../../utils/appDataVolume'; -import { getAppVersion } from '../../utils/appVersion'; import notificationRoutes from './notifications'; import radarrRoutes from './radarr'; import sonarrRoutes from './sonarr'; @@ -93,8 +95,8 @@ settingsRoutes.post('/plex', async (req, res, next) => { const settings = getSettings(); try { const admin = await userRepository.findOneOrFail({ - select: ['id', 'plexToken'], - order: { id: 'ASC' }, + select: { id: true, plexToken: true }, + where: { id: 1 }, }); Object.assign(settings.plex, req.body); @@ -129,8 +131,8 @@ settingsRoutes.get('/plex/devices/servers', async (req, res, next) => { const userRepository = getRepository(User); try { const admin = await userRepository.findOneOrFail({ - select: ['id', 'plexToken'], - order: { id: 'ASC' }, + select: { id: true, plexToken: true }, + where: { id: 1 }, }); const plexTvClient = admin.plexToken ? new PlexTvAPI(admin.plexToken) @@ -208,8 +210,8 @@ settingsRoutes.get('/plex/library', async (req, res) => { if (req.query.sync) { const userRepository = getRepository(User); const admin = await userRepository.findOneOrFail({ - select: ['id', 'plexToken'], - order: { id: 'ASC' }, + select: { id: true, plexToken: true }, + where: { id: 1 }, }); const plexapi = new PlexAPI({ plexToken: admin.plexToken }); @@ -262,6 +264,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res) => { const userRepository = getRepository(User); const admin = await userRepository.findOneOrFail({ select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'], + where: { id: 1 }, order: { id: 'ASC' }, }); const jellyfinClient = new JellyfinAPI( @@ -312,6 +315,7 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => { const userRepository = getRepository(User); const admin = await userRepository.findOneOrFail({ select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'], + where: { id: 1 }, order: { id: 'ASC' }, }); const jellyfinClient = new JellyfinAPI( @@ -390,8 +394,8 @@ settingsRoutes.get( try { const admin = await userRepository.findOneOrFail({ - select: ['id', 'plexToken'], - order: { id: 'ASC' }, + select: { id: true, plexToken: true }, + where: { id: 1 }, }); const plexApi = new PlexTvAPI(admin.plexToken ?? ''); const plexUsers = (await plexApi.getUsers()).MediaContainer.User.map( diff --git a/server/routes/settings/notifications.ts b/server/routes/settings/notifications.ts index 5a337237d..5a38555ca 100644 --- a/server/routes/settings/notifications.ts +++ b/server/routes/settings/notifications.ts @@ -1,23 +1,24 @@ +import type { User } from '@server/entity/User'; +import { Notification } from '@server/lib/notifications'; +import type { NotificationAgent } from '@server/lib/notifications/agents/agent'; +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 { Router } from 'express'; -import { User } from '../../entity/User'; -import { Notification } from '../../lib/notifications'; -import { NotificationAgent } from '../../lib/notifications/agents/agent'; -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'; const notificationRoutes = Router(); const sendTestNotification = async (agent: NotificationAgent, user: User) => await agent.send(Notification.TEST_NOTIFICATION, { + notifySystem: true, notifyAdmin: false, notifyUser: user, subject: 'Test Notification', @@ -247,7 +248,7 @@ notificationRoutes.post('/webpush/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, - message: 'User information missing from request', + message: 'User information is missing from the request.', }); } @@ -363,7 +364,7 @@ notificationRoutes.post('/lunasea/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, - message: 'User information missing from request', + message: 'User information is missing from the request.', }); } @@ -384,34 +385,26 @@ notificationRoutes.get('/gotify', (_req, res) => { res.status(200).json(settings.notifications.agents.gotify); }); -notificationRoutes.post('/gotify', (req, rest) => { +notificationRoutes.post('/gotify', (req, res) => { const settings = getSettings(); settings.notifications.agents.gotify = req.body; settings.save(); - rest.status(200).json(settings.notifications.agents.gotify); + res.status(200).json(settings.notifications.agents.gotify); }); -notificationRoutes.post('/gotify/test', async (req, rest, next) => { +notificationRoutes.post('/gotify/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, - message: 'User information is missing from request', + message: 'User information is missing from the request.', }); } const gotifyAgent = new GotifyAgent(req.body); - if ( - await gotifyAgent.send(Notification.TEST_NOTIFICATION, { - notifyAdmin: false, - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }) - ) { - return rest.status(204).send(); + if (await sendTestNotification(gotifyAgent, req.user)) { + return res.status(204).send(); } else { return next({ status: 500, diff --git a/server/routes/settings/radarr.ts b/server/routes/settings/radarr.ts index a33bfcdba..c2b0a6f52 100644 --- a/server/routes/settings/radarr.ts +++ b/server/routes/settings/radarr.ts @@ -1,7 +1,8 @@ +import RadarrAPI from '@server/api/servarr/radarr'; +import type { RadarrSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; import { Router } from 'express'; -import RadarrAPI from '../../api/servarr/radarr'; -import { getSettings, RadarrSettings } from '../../lib/settings'; -import logger from '../../logger'; const radarrRoutes = Router(); diff --git a/server/routes/settings/sonarr.ts b/server/routes/settings/sonarr.ts index da5a5bb3f..358d07002 100644 --- a/server/routes/settings/sonarr.ts +++ b/server/routes/settings/sonarr.ts @@ -1,7 +1,8 @@ +import SonarrAPI from '@server/api/servarr/sonarr'; +import type { SonarrSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; import { Router } from 'express'; -import SonarrAPI from '../../api/servarr/sonarr'; -import { getSettings, SonarrSettings } from '../../lib/settings'; -import logger from '../../logger'; const sonarrRoutes = Router(); diff --git a/server/routes/tv.ts b/server/routes/tv.ts index 201e7afe3..d45e40620 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -1,11 +1,11 @@ +import RottenTomatoes from '@server/api/rottentomatoes'; +import TheMovieDb from '@server/api/themoviedb'; +import { MediaType } from '@server/constants/media'; +import Media from '@server/entity/Media'; +import logger from '@server/logger'; +import { mapTvResult } from '@server/models/Search'; +import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv'; import { Router } from 'express'; -import RottenTomatoes from '../api/rottentomatoes'; -import TheMovieDb from '../api/themoviedb'; -import { MediaType } from '../constants/media'; -import Media from '../entity/Media'; -import logger from '../logger'; -import { mapTvResult } from '../models/Search'; -import { mapSeasonWithEpisodes, mapTvDetails } from '../models/Tv'; const tvRoutes = Router(); diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 5811fc05f..a875ca1fc 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -1,26 +1,28 @@ -import { Router } from 'express'; -import gravatarUrl from 'gravatar-url'; -import { findIndex, sortBy } from 'lodash'; -import { getRepository, In, Not } from 'typeorm'; -import JellyfinAPI from '../../api/jellyfin'; -import PlexTvAPI from '../../api/plextv'; -import TautulliAPI from '../../api/tautulli'; -import { MediaType } from '../../constants/media'; -import { UserType } from '../../constants/user'; -import Media from '../../entity/Media'; -import { MediaRequest } from '../../entity/MediaRequest'; -import { User } from '../../entity/User'; -import { UserPushSubscription } from '../../entity/UserPushSubscription'; -import { +import JellyfinAPI from '@server/api/jellyfin'; +import PlexTvAPI from '@server/api/plextv'; +import TautulliAPI from '@server/api/tautulli'; +import { MediaType } from '@server/constants/media'; +import { UserType } from '@server/constants/user'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { MediaRequest } from '@server/entity/MediaRequest'; +import { User } from '@server/entity/User'; +import { UserPushSubscription } from '@server/entity/UserPushSubscription'; +import type { WatchlistResponse } from '@server/interfaces/api/discoverInterfaces'; +import type { QuotaResponse, UserRequestsResponse, UserResultsResponse, UserWatchDataResponse, -} from '../../interfaces/api/userInterfaces'; -import { hasPermission, Permission } from '../../lib/permissions'; -import { getSettings } from '../../lib/settings'; -import logger from '../../logger'; -import { isAuthenticated } from '../../middleware/auth'; +} from '@server/interfaces/api/userInterfaces'; +import { hasPermission, Permission } from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { isAuthenticated } from '@server/middleware/auth'; +import { Router } from 'express'; +import gravatarUrl from 'gravatar-url'; +import { findIndex, sortBy } from 'lodash'; +import { In } from 'typeorm'; import userSettingsRoutes from './usersettings'; const router = Router(); @@ -259,12 +261,7 @@ export const canMakePermissionsChange = ( user?: User ): boolean => // Only let the owner grant admin privileges - !(hasPermission(Permission.ADMIN, permissions) && user?.id !== 1) || - // Only let users with the manage settings permission, grant the same permission - !( - hasPermission(Permission.MANAGE_SETTINGS, permissions) && - !hasPermission(Permission.MANAGE_SETTINGS, user?.permissions ?? 0) - ); + !(hasPermission(Permission.ADMIN, permissions) && user?.id !== 1); router.put< Record, @@ -283,8 +280,12 @@ router.put< const userRepository = getRepository(User); - const users = await userRepository.findByIds(req.body.ids, { - ...(!isOwner ? { id: Not(1) } : {}), + const users: User[] = await userRepository.find({ + where: { + id: In( + isOwner ? req.body.ids : req.body.ids.filter((id) => Number(id) !== 1) + ), + }, }); const updatedUsers = await Promise.all( @@ -351,7 +352,7 @@ router.delete<{ id: string }>( const user = await userRepository.findOne({ where: { id: Number(req.params.id) }, - relations: ['requests'], + relations: { requests: true }, }); if (!user) { @@ -410,8 +411,8 @@ router.post( // taken from auth.ts const mainUser = await userRepository.findOneOrFail({ - select: ['id', 'plexToken'], - order: { id: 'ASC' }, + select: { id: true, plexToken: true }, + where: { id: 1 }, }); const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); @@ -477,6 +478,7 @@ router.post( // taken from auth.ts const admin = await userRepository.findOneOrFail({ + where: { id: 1 }, select: [ 'id', 'jellyfinAuthToken', @@ -598,7 +600,7 @@ router.get<{ id: string }, UserWatchDataResponse>( try { const user = await getRepository(User).findOneOrFail({ where: { id: Number(req.params.id) }, - select: ['id', 'plexId'], + select: { id: true, plexId: true }, }); const tautulli = new TautulliAPI(settings); @@ -680,4 +682,60 @@ router.get<{ id: string }, UserWatchDataResponse>( } ); +router.get<{ id: string; page?: number }, WatchlistResponse>( + '/:id/watchlist', + async (req, res, next) => { + if ( + Number(req.params.id) !== req.user?.id && + !req.user?.hasPermission( + [Permission.MANAGE_REQUESTS, Permission.WATCHLIST_VIEW], + { + type: 'or', + } + ) + ) { + return next({ + status: 403, + message: + "You do not have permission to view this user's Plex Watchlist.", + }); + } + + const itemsPerPage = 20; + const page = req.params.page ?? 1; + const offset = (page - 1) * itemsPerPage; + + const user = await getRepository(User).findOneOrFail({ + where: { id: Number(req.params.id) }, + select: { id: true, plexToken: true }, + }); + + if (!user?.plexToken) { + // We will just return an empty array if the user has no Plex token + return res.json({ + page: 1, + totalPages: 1, + totalResults: 0, + results: [], + }); + } + + const plexTV = new PlexTvAPI(user.plexToken); + + const watchlist = await plexTV.getWatchlist({ offset }); + + return res.json({ + page, + totalPages: Math.ceil(watchlist.size / itemsPerPage), + totalResults: watchlist.size, + results: watchlist.items.map((item) => ({ + ratingKey: item.ratingKey, + title: item.title, + mediaType: item.type === 'show' ? 'tv' : 'movie', + tmdbId: item.tmdbId, + })), + }); + } +); + export default router; diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index a05311a22..9b9a11ece 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -1,16 +1,16 @@ -import { Router } from 'express'; -import { getRepository } from 'typeorm'; -import { canMakePermissionsChange } from '.'; -import { User } from '../../entity/User'; -import { UserSettings } from '../../entity/UserSettings'; -import { +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import { UserSettings } from '@server/entity/UserSettings'; +import type { UserSettingsGeneralResponse, UserSettingsNotificationsResponse, -} from '../../interfaces/api/userSettingsInterfaces'; -import { Permission } from '../../lib/permissions'; -import { getSettings } from '../../lib/settings'; -import logger from '../../logger'; -import { isAuthenticated } from '../../middleware/auth'; +} from '@server/interfaces/api/userSettingsInterfaces'; +import { Permission } from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { isAuthenticated } from '@server/middleware/auth'; +import { Router } from 'express'; +import { canMakePermissionsChange } from '.'; const isOwnProfileOrAdmin = (): Middleware => { const authMiddleware: Middleware = (req, res, next) => { @@ -64,6 +64,8 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>( globalMovieQuotaLimit: defaultQuotas.movie.quotaLimit, globalTvQuotaDays: defaultQuotas.tv.quotaDays, globalTvQuotaLimit: defaultQuotas.tv.quotaLimit, + watchlistSyncMovies: user.settings?.watchlistSyncMovies, + watchlistSyncTv: user.settings?.watchlistSyncTv, }); } catch (e) { next({ status: 500, message: e.message }); @@ -115,12 +117,16 @@ userSettingsRoutes.post< locale: req.body.locale, region: req.body.region, originalLanguage: req.body.originalLanguage, + watchlistSyncMovies: req.body.watchlistSyncMovies, + watchlistSyncTv: req.body.watchlistSyncTv, }); } else { user.settings.discordId = req.body.discordId; user.settings.locale = req.body.locale; user.settings.region = req.body.region; user.settings.originalLanguage = req.body.originalLanguage; + user.settings.watchlistSyncMovies = req.body.watchlistSyncMovies; + user.settings.watchlistSyncTv = req.body.watchlistSyncTv; user.email = req.body.email ?? user.email; } @@ -132,6 +138,8 @@ userSettingsRoutes.post< locale: user.settings.locale, region: user.settings.region, originalLanguage: user.settings.originalLanguage, + watchlistSyncMovies: user.settings.watchlistSyncMovies, + watchlistSyncTv: user.settings.watchlistSyncTv, email: user.email, }); } catch (e) { diff --git a/server/scripts/prepareTestDb.ts b/server/scripts/prepareTestDb.ts new file mode 100644 index 000000000..7caede41f --- /dev/null +++ b/server/scripts/prepareTestDb.ts @@ -0,0 +1,72 @@ +import { UserType } from '@server/constants/user'; +import dataSource, { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import { copyFileSync } from 'fs'; +import gravatarUrl from 'gravatar-url'; +import path from 'path'; + +const prepareDb = async () => { + // Copy over test settings.json + copyFileSync( + path.join(__dirname, '../../cypress/config/settings.cypress.json'), + path.join(__dirname, '../../config/settings.json') + ); + + // Connect to DB and seed test data + const dbConnection = await dataSource.initialize(); + + if (process.env.PRESERVE_DB !== 'true') { + await dbConnection.dropDatabase(); + } + + // Run migrations in production + if (process.env.WITH_MIGRATIONS === 'true') { + await dbConnection.runMigrations(); + } else { + await dbConnection.synchronize(); + } + + const userRepository = getRepository(User); + + const admin = await userRepository.findOne({ + select: { id: true, plexId: true }, + where: { id: 1 }, + }); + + // Create the admin user + const user = + (await userRepository.findOne({ + where: { email: 'admin@seerr.dev' }, + })) ?? new User(); + user.plexId = admin?.plexId ?? 1; + user.plexToken = '1234'; + user.plexUsername = 'admin'; + user.username = 'admin'; + user.email = 'admin@seerr.dev'; + user.userType = UserType.PLEX; + await user.setPassword('test1234'); + user.permissions = 2; + user.avatar = gravatarUrl('admin@seerr.dev', { default: 'mm', size: 200 }); + await userRepository.save(user); + + // Create the other user + const otherUser = + (await userRepository.findOne({ + where: { email: 'friend@seerr.dev' }, + })) ?? new User(); + otherUser.plexId = admin?.plexId ?? 1; + otherUser.plexToken = '1234'; + otherUser.plexUsername = 'friend'; + otherUser.username = 'friend'; + otherUser.email = 'friend@seerr.dev'; + otherUser.userType = UserType.PLEX; + await otherUser.setPassword('test1234'); + otherUser.permissions = 32; + otherUser.avatar = gravatarUrl('friend@seerr.dev', { + default: 'mm', + size: 200, + }); + await userRepository.save(otherUser); +}; + +prepareDb(); diff --git a/server/subscriber/IssueCommentSubscriber.ts b/server/subscriber/IssueCommentSubscriber.ts index 1b1b7b55c..cb95ba008 100644 --- a/server/subscriber/IssueCommentSubscriber.ts +++ b/server/subscriber/IssueCommentSubscriber.ts @@ -1,18 +1,15 @@ +import TheMovieDb from '@server/api/themoviedb'; +import { IssueType, IssueTypeName } from '@server/constants/issue'; +import { MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import IssueComment from '@server/entity/IssueComment'; +import Media from '@server/entity/Media'; +import notificationManager, { Notification } from '@server/lib/notifications'; +import { Permission } from '@server/lib/permissions'; +import logger from '@server/logger'; import { sortBy } from 'lodash'; -import { - EntitySubscriberInterface, - EventSubscriber, - getRepository, - InsertEvent, -} from 'typeorm'; -import TheMovieDb from '../api/themoviedb'; -import { IssueType, IssueTypeName } from '../constants/issue'; -import { MediaType } from '../constants/media'; -import IssueComment from '../entity/IssueComment'; -import Media from '../entity/Media'; -import notificationManager, { Notification } from '../lib/notifications'; -import { Permission } from '../lib/permissions'; -import logger from '../logger'; +import type { EntitySubscriberInterface, InsertEvent } from 'typeorm'; +import { EventSubscriber } from 'typeorm'; @EventSubscriber() export class IssueCommentSubscriber @@ -31,7 +28,7 @@ export class IssueCommentSubscriber const issue = ( await getRepository(IssueComment).findOneOrFail({ where: { id: entity.id }, - relations: ['issue'], + relations: { issue: true }, }) ).issue; @@ -72,6 +69,7 @@ export class IssueCommentSubscriber media, image, notifyAdmin: true, + notifySystem: true, notifyUser: !issue.createdBy.hasPermission(Permission.MANAGE_ISSUES) && issue.createdBy.id !== entity.user.id diff --git a/server/subscriber/IssueSubscriber.ts b/server/subscriber/IssueSubscriber.ts index b593095cd..eb4020415 100644 --- a/server/subscriber/IssueSubscriber.ts +++ b/server/subscriber/IssueSubscriber.ts @@ -1,17 +1,17 @@ +import TheMovieDb from '@server/api/themoviedb'; +import { IssueStatus, IssueType, IssueTypeName } from '@server/constants/issue'; +import { MediaType } from '@server/constants/media'; +import Issue from '@server/entity/Issue'; +import notificationManager, { Notification } from '@server/lib/notifications'; +import { Permission } from '@server/lib/permissions'; +import logger from '@server/logger'; import { sortBy } from 'lodash'; -import { +import type { EntitySubscriberInterface, - EventSubscriber, InsertEvent, UpdateEvent, } from 'typeorm'; -import TheMovieDb from '../api/themoviedb'; -import { IssueStatus, IssueType, IssueTypeName } from '../constants/issue'; -import { MediaType } from '../constants/media'; -import Issue from '../entity/Issue'; -import notificationManager, { Notification } from '../lib/notifications'; -import { Permission } from '../lib/permissions'; -import logger from '../logger'; +import { EventSubscriber } from 'typeorm'; @EventSubscriber() export class IssueSubscriber implements EntitySubscriberInterface { @@ -84,6 +84,7 @@ export class IssueSubscriber implements EntitySubscriberInterface { image, extra, notifyAdmin: true, + notifySystem: true, notifyUser: !entity.createdBy.hasPermission(Permission.MANAGE_ISSUES) && (type === Notification.ISSUE_RESOLVED || diff --git a/server/subscriber/MediaSubscriber.ts b/server/subscriber/MediaSubscriber.ts index 01752b0d1..eecfe6f3d 100644 --- a/server/subscriber/MediaSubscriber.ts +++ b/server/subscriber/MediaSubscriber.ts @@ -1,18 +1,18 @@ -import { truncate } from 'lodash'; +import TheMovieDb from '@server/api/themoviedb'; import { - EntitySubscriberInterface, - EventSubscriber, - getRepository, - Not, - UpdateEvent, -} from 'typeorm'; -import TheMovieDb from '../api/themoviedb'; -import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media'; -import Media from '../entity/Media'; -import { MediaRequest } from '../entity/MediaRequest'; -import Season from '../entity/Season'; -import notificationManager, { Notification } from '../lib/notifications'; -import logger from '../logger'; + MediaRequestStatus, + MediaStatus, + MediaType, +} from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { MediaRequest } from '@server/entity/MediaRequest'; +import Season from '@server/entity/Season'; +import notificationManager, { Notification } from '@server/lib/notifications'; +import logger from '@server/logger'; +import { truncate } from 'lodash'; +import type { EntitySubscriberInterface, UpdateEvent } from 'typeorm'; +import { EventSubscriber, In, Not } from 'typeorm'; @EventSubscriber() export class MediaSubscriber implements EntitySubscriberInterface { @@ -29,7 +29,9 @@ export class MediaSubscriber implements EntitySubscriberInterface { const requestRepository = getRepository(MediaRequest); const relatedRequests = await requestRepository.find({ where: { - media: entity, + media: { + id: entity.id, + }, is4k, status: Not(MediaRequestStatus.DECLINED), }, @@ -47,6 +49,7 @@ export class MediaSubscriber implements EntitySubscriberInterface { { event: `${is4k ? '4K ' : ''}Movie Request Now Available`, notifyAdmin: false, + notifySystem: true, notifyUser: request.requestedBy, subject: `${movie.title}${ movie.release_date @@ -89,7 +92,7 @@ export class MediaSubscriber implements EntitySubscriberInterface { ) .map((season) => season.seasonNumber); const oldSeasonIds = dbEntity.seasons.map((season) => season.id); - const oldSeasons = await seasonRepository.findByIds(oldSeasonIds); + const oldSeasons = await seasonRepository.findBy({ id: In(oldSeasonIds) }); const oldAvailableSeasons = oldSeasons .filter( (season) => @@ -109,7 +112,9 @@ export class MediaSubscriber implements EntitySubscriberInterface { for (const changedSeasonNumber of changedSeasons) { const requests = await requestRepository.find({ where: { - media: entity, + media: { + id: entity.id, + }, is4k, status: Not(MediaRequestStatus.DECLINED), }, @@ -143,6 +148,7 @@ export class MediaSubscriber implements EntitySubscriberInterface { omission: '…', }), notifyAdmin: false, + notifySystem: true, notifyUser: request.requestedBy, image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`, media: entity, @@ -172,7 +178,7 @@ export class MediaSubscriber implements EntitySubscriberInterface { const requestRepository = getRepository(MediaRequest); const requests = await requestRepository.find({ - where: { media: event.id }, + where: { media: { id: event.id } }, }); for (const request of requests) { diff --git a/server/tsconfig.json b/server/tsconfig.json index d245100d9..ec4b9004d 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -4,7 +4,11 @@ "target": "ES2020", "module": "commonjs", "outDir": "../dist", - "noEmit": false + "noEmit": false, + "baseUrl": ".", + "paths": { + "@server/*": ["*"] + } }, "include": ["**/*.ts"] } diff --git a/server/types/express.d.ts b/server/types/express.d.ts index ee7fd9724..7b82477ad 100644 --- a/server/types/express.d.ts +++ b/server/types/express.d.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ +import type { User } from '@server/entity/User'; import type { NextFunction, Request, Response } from 'express'; -import type { User } from '../entity/User'; declare global { namespace Express { diff --git a/server/utils/appVersion.ts b/server/utils/appVersion.ts index 923d47089..d01a08a97 100644 --- a/server/utils/appVersion.ts +++ b/server/utils/appVersion.ts @@ -1,6 +1,6 @@ +import logger from '@server/logger'; import { existsSync } from 'fs'; import path from 'path'; -import logger from '../logger'; const COMMIT_TAG_PATH = path.join(__dirname, '../../committag.json'); let commitTag = 'local'; diff --git a/server/utils/dateHelpers.ts b/server/utils/dateHelpers.ts new file mode 100644 index 000000000..4684d7835 --- /dev/null +++ b/server/utils/dateHelpers.ts @@ -0,0 +1,4 @@ +import { addYears } from 'date-fns'; +import { Between } from 'typeorm'; + +export const AfterDate = (date: Date) => Between(date, addYears(date, 100)); diff --git a/server/utils/restartFlag.ts b/server/utils/restartFlag.ts new file mode 100644 index 000000000..387ec5ce4 --- /dev/null +++ b/server/utils/restartFlag.ts @@ -0,0 +1,23 @@ +import type { MainSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; + +class RestartFlag { + private settings: MainSettings; + + public initializeSettings(settings: MainSettings): void { + this.settings = { ...settings }; + } + + public isSet(): boolean { + const settings = getSettings().main; + + return ( + this.settings.csrfProtection !== settings.csrfProtection || + this.settings.trustProxy !== settings.trustProxy + ); + } +} + +const restartFlag = new RestartFlag(); + +export default restartFlag; diff --git a/server/utils/typeHelpers.ts b/server/utils/typeHelpers.ts index 04070244b..507ece8cd 100644 --- a/server/utils/typeHelpers.ts +++ b/server/utils/typeHelpers.ts @@ -5,7 +5,7 @@ import type { TmdbPersonResult, TmdbTvDetails, TmdbTvResult, -} from '../api/themoviedb/interfaces'; +} from '@server/api/themoviedb/interfaces'; export const isMovie = ( movie: TmdbMovieResult | TmdbTvResult | TmdbPersonResult diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 0a7099ccf..3b693643a 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -8,10 +8,15 @@ description: > base: core18 confinement: strict +architectures: + - build-on: amd64 + - build-on: arm64 + - build-on: armhf + parts: overseerr: plugin: nodejs - nodejs-version: '16.14.0' + nodejs-version: '16.17.0' nodejs-package-manager: 'yarn' nodejs-yarn-version: v1.22.17 build-packages: @@ -31,13 +36,16 @@ parts: override-pull: | snapcraftctl pull # Get information to determine snap grade and version + git config --global --add safe.directory /data/parts/overseerr/src + #setup yarn.rc + echo "--install.frozen-lockfile\n--install.network-timeout 1000000" > .yarnrc BRANCH=$(git rev-parse --abbrev-ref HEAD) COMMIT=$(git rev-parse HEAD) COMMIT_SHORT=$(git rev-parse --short HEAD) VERSION='v'$(cat package.json | grep 'version' | head -1 | sed 's/.*"\(.*\)"\,/\1/') if [ "$VERSION" = "v0.1.0" ]; then SNAP_VERSION=$COMMIT_SHORT - GRADE=devel + GRADE=stable else SNAP_VERSION=$VERSION GRADE=stable @@ -57,6 +65,7 @@ parts: snapcraftctl set-grade "$GRADE" build-environment: - PATH: '$SNAPCRAFT_PART_BUILD/node_modules/.bin:$SNAPCRAFT_PART_BUILD/../npm/bin:$PATH' + - CYPRESS_INSTALL_BINARY: '0' override-build: | set -e # Set COMMIT_TAG before the build begins @@ -77,7 +86,7 @@ parts: prime: [.next, ./*] apps: - deamon: + daemon: command: /bin/sh -c "cd $SNAP && node dist/index.js" daemon: simple restart-condition: on-failure diff --git a/src/assets/infinity.svg b/src/assets/infinity.svg new file mode 100644 index 000000000..054149f8e --- /dev/null +++ b/src/assets/infinity.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/AirDateBadge/index.tsx b/src/components/AirDateBadge/index.tsx new file mode 100644 index 000000000..fb9268f6c --- /dev/null +++ b/src/components/AirDateBadge/index.tsx @@ -0,0 +1,62 @@ +import Badge from '@app/components/Common/Badge'; +import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; + +const messages = defineMessages({ + airedrelative: 'Aired {relativeTime}', + airsrelative: 'Airing {relativeTime}', +}); + +type AirDateBadgeProps = { + airDate: string; +}; + +const AirDateBadge = ({ airDate }: AirDateBadgeProps) => { + const WEEK = 1000 * 60 * 60 * 24 * 8; + const intl = useIntl(); + const dAirDate = new Date(airDate); + const nowDate = new Date(); + const alreadyAired = dAirDate.getTime() < nowDate.getTime(); + + const compareWeek = new Date( + alreadyAired ? Date.now() - WEEK : Date.now() + WEEK + ); + + let showRelative = false; + + if ( + (alreadyAired && dAirDate.getTime() > compareWeek.getTime()) || + (!alreadyAired && dAirDate.getTime() < compareWeek.getTime()) + ) { + showRelative = true; + } + + return ( +
+ + {intl.formatDate(dAirDate, { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + + {showRelative && ( + + {intl.formatMessage( + alreadyAired ? messages.airedrelative : messages.airsrelative, + { + relativeTime: ( + + ), + } + )} + + )} +
+ ); +}; + +export default AirDateBadge; diff --git a/src/components/AppDataWarning/index.tsx b/src/components/AppDataWarning/index.tsx index fce97bd53..21c3dbaef 100644 --- a/src/components/AppDataWarning/index.tsx +++ b/src/components/AppDataWarning/index.tsx @@ -1,14 +1,13 @@ -import React from 'react'; +import Alert from '@app/components/Common/Alert'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import Alert from '../Common/Alert'; const messages = defineMessages({ dockerVolumeMissingDescription: 'The {appDataPath} volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.', }); -const AppDataWarning: React.FC = () => { +const AppDataWarning = () => { const intl = useIntl(); const { data, error } = useSWR<{ appData: boolean; appDataPath: string }>( '/api/v1/status/appdata' @@ -27,9 +26,9 @@ const AppDataWarning: React.FC = () => { {!data.appData && ( {msg}; - }, + code: (msg: React.ReactNode) => ( + {msg} + ), appDataPath: data.appDataPath, })} /> diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index 839f019ad..52bd8a269 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -1,24 +1,24 @@ +import ButtonWithDropdown from '@app/components/Common/ButtonWithDropdown'; +import CachedImage from '@app/components/Common/CachedImage'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import RequestModal from '@app/components/RequestModal'; +import Slider from '@app/components/Slider'; +import StatusBadge from '@app/components/StatusBadge'; +import TitleCard from '@app/components/TitleCard'; +import useSettings from '@app/hooks/useSettings'; +import { Permission, useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; +import Error from '@app/pages/_error'; import { DownloadIcon } from '@heroicons/react/outline'; +import { MediaStatus } from '@server/constants/media'; +import type { Collection } from '@server/models/Collection'; import { uniq } from 'lodash'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React, { useState } from 'react'; +import { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import { MediaStatus } from '../../../server/constants/media'; -import type { Collection } from '../../../server/models/Collection'; -import useSettings from '../../hooks/useSettings'; -import { Permission, useUser } from '../../hooks/useUser'; -import globalMessages from '../../i18n/globalMessages'; -import Error from '../../pages/_error'; -import ButtonWithDropdown from '../Common/ButtonWithDropdown'; -import CachedImage from '../Common/CachedImage'; -import LoadingSpinner from '../Common/LoadingSpinner'; -import PageTitle from '../Common/PageTitle'; -import RequestModal from '../RequestModal'; -import Slider from '../Slider'; -import StatusBadge from '../StatusBadge'; -import TitleCard from '../TitleCard'; const messages = defineMessages({ overview: 'Overview', @@ -31,9 +31,7 @@ interface CollectionDetailsProps { collection?: Collection; } -const CollectionDetails: React.FC = ({ - collection, -}) => { +const CollectionDetails = ({ collection }: CollectionDetailsProps) => { const intl = useIntl(); const router = useRouter(); const settings = useSettings(); diff --git a/src/components/Common/Accordion/index.tsx b/src/components/Common/Accordion/index.tsx index 67e883fe0..49187bd03 100644 --- a/src/components/Common/Accordion/index.tsx +++ b/src/components/Common/Accordion/index.tsx @@ -1,9 +1,9 @@ -import * as React from 'react'; +import type * as React from 'react'; import { useState } from 'react'; import AnimateHeight from 'react-animate-height'; export interface AccordionProps { - children: (args: AccordionChildProps) => React.ReactElement | null; + children: (args: AccordionChildProps) => JSX.Element; /** If true, only one accordion item can be open at any time */ single?: boolean; /** If true, at least one accordion item will always be open */ @@ -13,22 +13,27 @@ export interface AccordionProps { export interface AccordionChildProps { openIndexes: number[]; handleClick(index: number): void; - AccordionContent: any; + AccordionContent: typeof AccordionContent; } -export const AccordionContent: React.FC<{ isOpen: boolean }> = ({ +type AccordionContentProps = { + isOpen: boolean; + children: React.ReactNode; +}; + +export const AccordionContent = ({ isOpen, children, -}) => { +}: AccordionContentProps) => { return {children}; }; -const Accordion: React.FC = ({ +const Accordion = ({ single, atLeastOne, initialOpenIndexes, children, -}) => { +}: AccordionProps) => { const initialState = initialOpenIndexes || (atLeastOne && [0]) || []; const [openIndexes, setOpenIndexes] = useState(initialState); diff --git a/src/components/Common/Alert/index.tsx b/src/components/Common/Alert/index.tsx index e9789c706..8ffb4a255 100644 --- a/src/components/Common/Alert/index.tsx +++ b/src/components/Common/Alert/index.tsx @@ -3,16 +3,17 @@ import { InformationCircleIcon, XCircleIcon, } from '@heroicons/react/solid'; -import React from 'react'; interface AlertProps { title?: React.ReactNode; type?: 'warning' | 'info' | 'error'; + children?: React.ReactNode; } -const Alert: React.FC = ({ title, children, type }) => { +const Alert = ({ title, children, type }: AlertProps) => { let design = { - bgColor: 'bg-yellow-600', + bgColor: + 'border border-yellow-500 backdrop-blur bg-yellow-400 bg-opacity-20', titleColor: 'text-yellow-100', textColor: 'text-yellow-300', svg: , @@ -21,9 +22,10 @@ const Alert: React.FC = ({ title, children, type }) => { switch (type) { case 'info': design = { - bgColor: 'bg-indigo-600', - titleColor: 'text-indigo-100', - textColor: 'text-indigo-300', + bgColor: + 'border border-indigo-500 backdrop-blur bg-indigo-400 bg-opacity-20', + titleColor: 'text-gray-100', + textColor: 'text-gray-300', svg: , }; break; diff --git a/src/components/Common/Badge/index.tsx b/src/components/Common/Badge/index.tsx index 33e55ab72..47ce6586c 100644 --- a/src/components/Common/Badge/index.tsx +++ b/src/components/Common/Badge/index.tsx @@ -2,17 +2,23 @@ import Link from 'next/link'; import React from 'react'; interface BadgeProps { - badgeType?: 'default' | 'primary' | 'danger' | 'warning' | 'success'; + badgeType?: + | 'default' + | 'primary' + | 'danger' + | 'warning' + | 'success' + | 'dark' + | 'light'; className?: string; href?: string; + children: React.ReactNode; } -const Badge: React.FC = ({ - badgeType = 'default', - className, - href, - children, -}) => { +const Badge = ( + { badgeType = 'default', className, href, children }: BadgeProps, + ref?: React.Ref +) => { const badgeStyle = [ 'px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap', ]; @@ -25,27 +31,47 @@ const Badge: React.FC = ({ switch (badgeType) { case 'danger': - badgeStyle.push('bg-red-600 !text-red-100'); + badgeStyle.push( + 'bg-red-600 bg-opacity-80 border-red-500 border !text-red-100' + ); if (href) { - badgeStyle.push('hover:bg-red-500'); + badgeStyle.push('hover:bg-red-500 bg-opacity-100'); } break; case 'warning': - badgeStyle.push('bg-yellow-500 !text-yellow-100'); + badgeStyle.push( + 'bg-yellow-500 bg-opacity-80 border-yellow-500 border !text-yellow-100' + ); if (href) { - badgeStyle.push('hover:bg-yellow-400'); + badgeStyle.push('hover:bg-yellow-500 hover:bg-opacity-100'); } break; case 'success': - badgeStyle.push('bg-green-500 !text-green-100'); + badgeStyle.push( + 'bg-green-500 bg-opacity-80 border border-green-500 !text-green-100' + ); if (href) { - badgeStyle.push('hover:bg-green-400'); + badgeStyle.push('hover:bg-green-500 hover:bg-opacity-100'); + } + break; + case 'dark': + badgeStyle.push('bg-gray-900 !text-gray-400'); + if (href) { + badgeStyle.push('hover:bg-gray-800'); + } + break; + case 'light': + badgeStyle.push('bg-gray-700 !text-gray-300'); + if (href) { + badgeStyle.push('hover:bg-gray-600'); } break; default: - badgeStyle.push('bg-indigo-500 !text-indigo-100'); + badgeStyle.push( + 'bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100' + ); if (href) { - badgeStyle.push('hover:bg-indigo-400'); + badgeStyle.push('hover:bg-indigo-500 bg-opacity-100'); } } @@ -60,6 +86,7 @@ const Badge: React.FC = ({ target="_blank" rel="noopener noreferrer" className={badgeStyle.join(' ')} + ref={ref as React.Ref} > {children} @@ -67,12 +94,24 @@ const Badge: React.FC = ({ } else if (href) { return ( - {children} + } + > + {children} + ); } else { - return {children}; + return ( + } + > + {children} + + ); } }; -export default Badge; +export default React.forwardRef(Badge) as typeof Badge; diff --git a/src/components/Common/Button/index.tsx b/src/components/Common/Button/index.tsx index f1083e5b2..d3f96ae98 100644 --- a/src/components/Common/Button/index.tsx +++ b/src/components/Common/Button/index.tsx @@ -1,4 +1,5 @@ -import React, { ForwardedRef } from 'react'; +import type { ForwardedRef } from 'react'; +import React from 'react'; export type ButtonType = | 'default' @@ -50,22 +51,22 @@ function Button

( switch (buttonType) { case 'primary': buttonStyle.push( - 'text-white bg-indigo-600 border-indigo-600 hover:bg-indigo-500 hover:border-indigo-500 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 active:border-indigo-700' + 'text-white border border-indigo-500 bg-indigo-600 bg-opacity-80 hover:bg-opacity-100 hover:border-indigo-500 focus:border-indigo-700 focus:ring-indigo active:bg-opacity-100 active:border-indigo-700' ); break; case 'danger': buttonStyle.push( - 'text-white bg-red-600 border-red-600 hover:bg-red-500 hover:border-red-500 focus:border-red-700 focus:ring-red active:bg-red-700 active:border-red-700' + 'text-white bg-red-600 bg-opacity-80 border-red-500 hover:bg-opacity-100 hover:border-red-500 focus:border-red-700 focus:ring-red active:bg-red-700 active:border-red-700' ); break; case 'warning': buttonStyle.push( - 'text-white bg-yellow-500 border-yellow-500 hover:bg-yellow-400 hover:border-yellow-400 focus:border-yellow-700 focus:ring-yellow active:bg-yellow-700 active:border-yellow-700' + 'text-white border border-yellow-500 backdrop-blur bg-yellow-500 bg-opacity-80 hover:bg-opacity-100 hover:border-yellow-400 focus:border-yellow-700 focus:ring-yellow active:bg-opacity-100 active:border-yellow-700' ); break; case 'success': buttonStyle.push( - 'text-white bg-green-500 border-green-500 hover:bg-green-400 hover:border-green-400 focus:border-green-700 focus:ring-green active:bg-green-700 active:border-green-700' + 'text-white bg-green-500 bg-opacity-80 border-green-500 hover:bg-opacity-100 hover:border-green-400 focus:border-green-700 focus:ring-green active:bg-opacity-100 active:border-green-700' ); break; case 'ghost': @@ -75,7 +76,7 @@ function Button

( break; default: buttonStyle.push( - 'text-gray-200 bg-gray-600 border-gray-600 hover:text-white hover:bg-gray-500 hover:border-gray-500 group-hover:text-white group-hover:bg-gray-500 group-hover:border-gray-500 focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-500 active:border-gray-500' + 'text-gray-200 bg-gray-800 bg-opacity-80 border-gray-600 hover:text-white hover:bg-gray-700 hover:border-gray-600 group-hover:text-white group-hover:bg-gray-700 group-hover:border-gray-600 focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-700 active:border-gray-600' ); } diff --git a/src/components/Common/ButtonWithDropdown/index.tsx b/src/components/Common/ButtonWithDropdown/index.tsx index 6edb4a11f..be6815b94 100644 --- a/src/components/Common/ButtonWithDropdown/index.tsx +++ b/src/components/Common/ButtonWithDropdown/index.tsx @@ -1,34 +1,29 @@ +import useClickOutside from '@app/hooks/useClickOutside'; +import { withProperties } from '@app/utils/typeHelpers'; +import { Transition } from '@headlessui/react'; import { ChevronDownIcon } from '@heroicons/react/solid'; -import React, { - AnchorHTMLAttributes, - ButtonHTMLAttributes, - ReactNode, - useRef, - useState, -} from 'react'; -import useClickOutside from '../../../hooks/useClickOutside'; -import { withProperties } from '../../../utils/typeHelpers'; -import Transition from '../../Transition'; +import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react'; +import { Fragment, useRef, useState } from 'react'; interface DropdownItemProps extends AnchorHTMLAttributes { buttonType?: 'primary' | 'ghost'; } -const DropdownItem: React.FC = ({ +const DropdownItem = ({ children, buttonType = 'primary', ...props -}) => { +}: DropdownItemProps) => { let styleClass = 'button-md text-white'; switch (buttonType) { case 'ghost': styleClass += - ' bg-gray-700 hover:bg-gray-600 focus:border-gray-500 focus:text-white'; + ' bg-transparent rounded hover:bg-gradient-to-br from-indigo-600 to-purple-600 text-white focus:border-gray-500 focus:text-white'; break; default: styleClass += - ' bg-indigo-600 hover:bg-indigo-500 focus:border-indigo-700 focus:text-white'; + ' bg-indigo-600 rounded hover:bg-indigo-500 focus:border-indigo-700 focus:text-white'; } return ( = ({ interface ButtonWithDropdownProps extends ButtonHTMLAttributes { - text: ReactNode; - dropdownIcon?: ReactNode; + text: React.ReactNode; + dropdownIcon?: React.ReactNode; buttonType?: 'primary' | 'ghost'; } -const ButtonWithDropdown: React.FC = ({ +const ButtonWithDropdown = ({ text, children, dropdownIcon, className, buttonType = 'primary', ...props -}) => { +}: ButtonWithDropdownProps) => { const [isOpen, setIsOpen] = useState(false); const buttonRef = useRef(null); useClickOutside(buttonRef, () => setIsOpen(false)); @@ -70,14 +65,15 @@ const ButtonWithDropdown: React.FC = ({ styleClasses.mainButtonClasses += ' bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'; styleClasses.dropdownSideButtonClasses = styleClasses.mainButtonClasses; - styleClasses.dropdownClasses += ' bg-gray-700'; + styleClasses.dropdownClasses += + ' bg-gray-800 border border-gray-700 bg-opacity-80 p-1 backdrop-blur'; break; default: styleClasses.mainButtonClasses += - ' bg-indigo-600 border-indigo-600 hover:bg-indigo-500 hover:border-indigo-500 active:bg-indigo-700 active:border-indigo-700 focus:ring-blue'; + ' bg-indigo-600 border-indigo-500 bg-opacity-80 hover:bg-opacity-100 hover:border-indigo-500 active:bg-indigo-700 active:border-indigo-700 focus:ring-blue'; styleClasses.dropdownSideButtonClasses += - ' bg-indigo-700 border-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue'; - styleClasses.dropdownClasses += ' bg-indigo-600'; + ' bg-indigo-600 bg-opacity-80 border-indigo-500 hover:bg-opacity-100 active:bg-opacity-100 focus:ring-blue'; + styleClasses.dropdownClasses += ' bg-indigo-600 p-1'; } return ( @@ -103,6 +99,7 @@ const ButtonWithDropdown: React.FC = ({ {dropdownIcon ? dropdownIcon : } = (props) => { +const CachedImage = (props: ImageProps) => { const { currentSettings } = useSettings(); return ; diff --git a/src/components/Common/ConfirmButton/index.tsx b/src/components/Common/ConfirmButton/index.tsx index df3c6572a..1f5756cb9 100644 --- a/src/components/Common/ConfirmButton/index.tsx +++ b/src/components/Common/ConfirmButton/index.tsx @@ -1,19 +1,20 @@ -import React, { useRef, useState } from 'react'; -import useClickOutside from '../../../hooks/useClickOutside'; -import Button from '../Button'; +import Button from '@app/components/Common/Button'; +import useClickOutside from '@app/hooks/useClickOutside'; +import { useRef, useState } from 'react'; interface ConfirmButtonProps { onClick: () => void; confirmText: React.ReactNode; className?: string; + children: React.ReactNode; } -const ConfirmButton: React.FC = ({ +const ConfirmButton = ({ onClick, children, confirmText, className, -}) => { +}: ConfirmButtonProps) => { const ref = useRef(null); useClickOutside(ref, () => setIsClicked(false)); const [isClicked, setIsClicked] = useState(false); diff --git a/src/components/Common/Header/index.tsx b/src/components/Common/Header/index.tsx index b7c88ddd9..1653a457d 100644 --- a/src/components/Common/Header/index.tsx +++ b/src/components/Common/Header/index.tsx @@ -1,22 +1,18 @@ -import React from 'react'; - interface HeaderProps { extraMargin?: number; subtext?: React.ReactNode; + children: React.ReactNode; } -const Header: React.FC = ({ - children, - extraMargin = 0, - subtext, -}) => { +const Header = ({ children, extraMargin = 0, subtext }: HeaderProps) => { return (

-

- - {children} - +

+ {children}

{subtext &&
{subtext}
}
diff --git a/src/components/Common/ImageFader/index.tsx b/src/components/Common/ImageFader/index.tsx index 5f68376c0..a57172414 100644 --- a/src/components/Common/ImageFader/index.tsx +++ b/src/components/Common/ImageFader/index.tsx @@ -1,10 +1,6 @@ -import React, { - ForwardRefRenderFunction, - HTMLAttributes, - useEffect, - useState, -} from 'react'; -import CachedImage from '../CachedImage'; +import CachedImage from '@app/components/Common/CachedImage'; +import type { ForwardRefRenderFunction, HTMLAttributes } from 'react'; +import React, { useEffect, useState } from 'react'; interface ImageFaderProps extends HTMLAttributes { backgroundImages: string[]; diff --git a/src/components/Common/List/index.tsx b/src/components/Common/List/index.tsx index a4b917235..32057ed1a 100644 --- a/src/components/Common/List/index.tsx +++ b/src/components/Common/List/index.tsx @@ -1,12 +1,12 @@ -import React from 'react'; -import { withProperties } from '../../../utils/typeHelpers'; +import { withProperties } from '@app/utils/typeHelpers'; interface ListItemProps { title: string; className?: string; + children: React.ReactNode; } -const ListItem: React.FC = ({ title, className, children }) => { +const ListItem = ({ title, className, children }: ListItemProps) => { return (
@@ -22,9 +22,10 @@ const ListItem: React.FC = ({ title, className, children }) => { interface ListProps { title: string; subTitle?: string; + children: React.ReactNode; } -const List: React.FC = ({ title, subTitle, children }) => { +const List = ({ title, subTitle, children }: ListProps) => { return ( <>
diff --git a/src/components/Common/ListView/index.tsx b/src/components/Common/ListView/index.tsx index 0c2a0e4ed..6f09f768b 100644 --- a/src/components/Common/ListView/index.tsx +++ b/src/components/Common/ListView/index.tsx @@ -1,30 +1,33 @@ -import React from 'react'; -import { useIntl } from 'react-intl'; -import { +import PersonCard from '@app/components/PersonCard'; +import TitleCard from '@app/components/TitleCard'; +import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard'; +import useVerticalScroll from '@app/hooks/useVerticalScroll'; +import globalMessages from '@app/i18n/globalMessages'; +import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces'; +import type { MovieResult, PersonResult, TvResult, -} from '../../../../server/models/Search'; -import useVerticalScroll from '../../../hooks/useVerticalScroll'; -import globalMessages from '../../../i18n/globalMessages'; -import PersonCard from '../../PersonCard'; -import TitleCard from '../../TitleCard'; +} from '@server/models/Search'; +import { useIntl } from 'react-intl'; -interface ListViewProps { +type ListViewProps = { items?: (TvResult | MovieResult | PersonResult)[]; + plexItems?: WatchlistItem[]; isEmpty?: boolean; isLoading?: boolean; isReachingEnd?: boolean; onScrollBottom: () => void; -} +}; -const ListView: React.FC = ({ +const ListView = ({ items, isEmpty, isLoading, onScrollBottom, isReachingEnd, -}) => { + plexItems, +}: ListViewProps) => { const intl = useIntl(); useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd); return ( @@ -35,6 +38,18 @@ const ListView: React.FC = ({
)}
{loginError && (
@@ -116,6 +122,7 @@ const LocalLogin: React.FC = ({ revalidate }) => { buttonType="primary" type="submit" disabled={isSubmitting || !isValid} + data-testid="local-signin-button" > diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index eb8f368bd..3c16bdcf6 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -1,21 +1,21 @@ +import Accordion from '@app/components/Common/Accordion'; +import ImageFader from '@app/components/Common/ImageFader'; +import PageTitle from '@app/components/Common/PageTitle'; +import LanguagePicker from '@app/components/Layout/LanguagePicker'; +import LocalLogin from '@app/components/Login/LocalLogin'; +import PlexLoginButton from '@app/components/PlexLoginButton'; +import useSettings from '@app/hooks/useSettings'; +import { useUser } from '@app/hooks/useUser'; +import { Transition } from '@headlessui/react'; import { XCircleIcon } from '@heroicons/react/solid'; +import { MediaServerType } from '@server/constants/server'; import axios from 'axios'; +import getConfig from 'next/config'; import { useRouter } from 'next/dist/client/router'; -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import { MediaServerType } from '../../../server/constants/server'; -import useSettings from '../../hooks/useSettings'; -import { useUser } from '../../hooks/useUser'; -import Accordion from '../Common/Accordion'; -import ImageFader from '../Common/ImageFader'; -import PageTitle from '../Common/PageTitle'; -import LanguagePicker from '../Layout/LanguagePicker'; -import PlexLoginButton from '../PlexLoginButton'; -import Transition from '../Transition'; import JellyfinLogin from './JellyfinLogin'; -import LocalLogin from './LocalLogin'; -import getConfig from 'next/config'; const messages = defineMessages({ signin: 'Sign In', @@ -25,7 +25,7 @@ const messages = defineMessages({ signinwithoverseerr: 'Use your {applicationTitle} account', }); -const Login: React.FC = () => { +const Login = () => { const intl = useIntl(); const [error, setError] = useState(''); const [isProcessing, setProcessing] = useState(false); @@ -78,7 +78,7 @@ const Login: React.FC = () => { `https://www.themoviedb.org/t/p/original${backdrop}` + (backdrop) => `https://image.tmdb.org/t/p/original${backdrop}` ) ?? [] } /> @@ -98,6 +98,7 @@ const Login: React.FC = () => { > <> = ({ show, mediaType, onClose, data, revalidate }) => { +const ManageSlideOver = ({ + show, + mediaType, + onClose, + data, + revalidate, +}: ManageSlideOverMovieProps | ManageSlideOverTvProps) => { const { user: currentUser, hasPermission } = useUser(); const intl = useIntl(); const settings = useSettings(); @@ -115,9 +115,9 @@ const ManageSlideOver: React.FC< <> {intl.formatMessage(messages.plays, { playCount, - strong: function strong(msg) { - return {msg}; - }, + strong: (msg: React.ReactNode) => ( + {msg} + ), })} ); @@ -141,7 +141,7 @@ const ManageSlideOver: React.FC<

{intl.formatMessage(messages.downloadstatus)}

-
+
    {data.mediaInfo?.downloadStatus?.map((status, index) => (
  • 0 && ( - <> +

    {intl.formatMessage(messages.manageModalIssues)}

    -
    +
      {openIssues.map((issue) => (
    - +
    )} {requests.length > 0 && (

    {intl.formatMessage(messages.manageModalRequests)}

    -
    +
      {requests.map((request) => (
    • {intl.formatMessage(messages.manageModalMedia)}

      - {!!watchData?.data && ( + {(watchData?.data || data.mediaInfo?.tautulliUrl) && (
      -
      -
      -
      -
      - {intl.formatMessage(messages.pastdays, { days: 7 })} + {!!watchData?.data && ( +
      +
      +
      +
      + {intl.formatMessage(messages.pastdays, { + days: 7, + })} +
      +
      + {styledPlayCount(watchData.data.playCount7Days)} +
      -
      - {styledPlayCount(watchData.data.playCount7Days)} +
      +
      + {intl.formatMessage(messages.pastdays, { + days: 30, + })} +
      +
      + {styledPlayCount(watchData.data.playCount30Days)} +
      +
      +
      +
      + {intl.formatMessage(messages.alltime)} +
      +
      + {styledPlayCount(watchData.data.playCount)} +
      -
      -
      - {intl.formatMessage(messages.pastdays, { - days: 30, - })} + {!!watchData.data.users.length && ( +
      + + {intl.formatMessage(messages.playedby)} + + + {watchData.data.users.map((user) => ( + + + {user.displayName} + + + ))} +
      -
      - {styledPlayCount(watchData.data.playCount30Days)} -
      -
      -
      -
      - {intl.formatMessage(messages.alltime)} -
      -
      - {styledPlayCount(watchData.data.playCount)} -
      -
      + )}
      - {!!watchData.data.users.length && ( -
      - - {intl.formatMessage(messages.playedby)} - - - {watchData.data.users.map((user) => ( - - - {user.displayName} - - - ))} - -
      - )} -
      + )} {data.mediaInfo?.tautulliUrl && ( @@ -302,7 +306,7 @@ const ManageSlideOver: React.FC< )}
      )} - {data?.mediaInfo?.serviceUrl && ( + {data.mediaInfo?.serviceUrl && (

      {intl.formatMessage(messages.manageModalMedia4k)}

      - {!!watchData?.data4k && ( + {(watchData?.data4k || data.mediaInfo?.tautulliUrl4k) && (
      -
      -
      -
      -
      - {intl.formatMessage(messages.pastdays, { days: 7 })} + {watchData?.data4k && ( +
      +
      +
      +
      + {intl.formatMessage(messages.pastdays, { + days: 7, + })} +
      +
      + {styledPlayCount(watchData.data4k.playCount7Days)} +
      -
      - {styledPlayCount(watchData.data4k.playCount7Days)} +
      +
      + {intl.formatMessage(messages.pastdays, { + days: 30, + })} +
      +
      + {styledPlayCount( + watchData.data4k.playCount30Days + )} +
      +
      +
      +
      + {intl.formatMessage(messages.alltime)} +
      +
      + {styledPlayCount(watchData.data4k.playCount)} +
      -
      -
      - {intl.formatMessage(messages.pastdays, { - days: 30, - })} + {!!watchData.data4k.users.length && ( +
      + + {intl.formatMessage(messages.playedby)} + + + {watchData.data4k.users.map((user) => ( + + + {user.displayName} + + + ))} +
      -
      - {styledPlayCount(watchData.data4k.playCount30Days)} -
      -
      -
      -
      - {intl.formatMessage(messages.alltime)} -
      -
      - {styledPlayCount(watchData.data4k.playCount)} -
      -
      + )}
      - {!!watchData.data4k.users.length && ( -
      - )} -
      + )} {data.mediaInfo?.tautulliUrl4k && ( @@ -487,7 +497,7 @@ const ManageSlideOver: React.FC< {intl.formatMessage(messages.manageModalClearMedia)} -
      +
      {intl.formatMessage(messages.manageModalClearMediaWarning, { mediaType: intl.formatMessage( mediaType === 'movie' ? messages.movie : messages.tvshow diff --git a/src/components/MediaSlider/ShowMoreCard/index.tsx b/src/components/MediaSlider/ShowMoreCard/index.tsx index f6bc2ccb4..99900ac9a 100644 --- a/src/components/MediaSlider/ShowMoreCard/index.tsx +++ b/src/components/MediaSlider/ShowMoreCard/index.tsx @@ -1,6 +1,6 @@ import { ArrowCircleRightIcon } from '@heroicons/react/solid'; import Link from 'next/link'; -import React, { useState } from 'react'; +import { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; const messages = defineMessages({ @@ -12,7 +12,7 @@ interface ShowMoreCardProps { posters: (string | undefined)[]; } -const ShowMoreCard: React.FC = ({ url, posters }) => { +const ShowMoreCard = ({ url, posters }: ShowMoreCardProps) => { const intl = useIntl(); const [isHovered, setHovered] = useState(false); return ( diff --git a/src/components/MediaSlider/index.tsx b/src/components/MediaSlider/index.tsx index 84c72822f..9a9bc054c 100644 --- a/src/components/MediaSlider/index.tsx +++ b/src/components/MediaSlider/index.tsx @@ -1,18 +1,18 @@ +import ShowMoreCard from '@app/components/MediaSlider/ShowMoreCard'; +import PersonCard from '@app/components/PersonCard'; +import Slider from '@app/components/Slider'; +import TitleCard from '@app/components/TitleCard'; +import useSettings from '@app/hooks/useSettings'; import { ArrowCircleRightIcon } from '@heroicons/react/outline'; -import Link from 'next/link'; -import React, { useEffect } from 'react'; -import useSWRInfinite from 'swr/infinite'; -import { MediaStatus } from '../../../server/constants/media'; +import { MediaStatus } from '@server/constants/media'; import type { MovieResult, PersonResult, TvResult, -} from '../../../server/models/Search'; -import useSettings from '../../hooks/useSettings'; -import PersonCard from '../PersonCard'; -import Slider from '../Slider'; -import TitleCard from '../TitleCard'; -import ShowMoreCard from './ShowMoreCard'; +} from '@server/models/Search'; +import Link from 'next/link'; +import { useEffect } from 'react'; +import useSWRInfinite from 'swr/infinite'; interface MixedResult { page: number; @@ -29,13 +29,13 @@ interface MediaSliderProps { hideWhenEmpty?: boolean; } -const MediaSlider: React.FC = ({ +const MediaSlider = ({ title, url, linkUrl, sliderKey, hideWhenEmpty = false, -}) => { +}: MediaSliderProps) => { const settings = useSettings(); const { data, error, setSize, size } = useSWRInfinite( (pageIndex: number, previousPageData: MixedResult | null) => { diff --git a/src/components/MovieDetails/MovieCast/index.tsx b/src/components/MovieDetails/MovieCast/index.tsx index 0cc9c2e03..2006e9dfb 100644 --- a/src/components/MovieDetails/MovieCast/index.tsx +++ b/src/components/MovieDetails/MovieCast/index.tsx @@ -1,20 +1,19 @@ +import Header from '@app/components/Common/Header'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import PersonCard from '@app/components/PersonCard'; +import Error from '@app/pages/_error'; +import type { MovieDetails } from '@server/models/Movie'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import { MovieDetails } from '../../../../server/models/Movie'; -import Error from '../../../pages/_error'; -import Header from '../../Common/Header'; -import LoadingSpinner from '../../Common/LoadingSpinner'; -import PageTitle from '../../Common/PageTitle'; -import PersonCard from '../../PersonCard'; const messages = defineMessages({ fullcast: 'Full Cast', }); -const MovieCast: React.FC = () => { +const MovieCast = () => { const router = useRouter(); const intl = useIntl(); const { data, error } = useSWR( diff --git a/src/components/MovieDetails/MovieCrew/index.tsx b/src/components/MovieDetails/MovieCrew/index.tsx index 14268e425..1cc43b05a 100644 --- a/src/components/MovieDetails/MovieCrew/index.tsx +++ b/src/components/MovieDetails/MovieCrew/index.tsx @@ -1,20 +1,19 @@ +import Header from '@app/components/Common/Header'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import PersonCard from '@app/components/PersonCard'; +import Error from '@app/pages/_error'; +import type { MovieDetails } from '@server/models/Movie'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import { MovieDetails } from '../../../../server/models/Movie'; -import Error from '../../../pages/_error'; -import Header from '../../Common/Header'; -import LoadingSpinner from '../../Common/LoadingSpinner'; -import PageTitle from '../../Common/PageTitle'; -import PersonCard from '../../PersonCard'; const messages = defineMessages({ fullcrew: 'Full Crew', }); -const MovieCrew: React.FC = () => { +const MovieCrew = () => { const router = useRouter(); const intl = useIntl(); const { data, error } = useSWR( diff --git a/src/components/MovieDetails/MovieRecommendations.tsx b/src/components/MovieDetails/MovieRecommendations.tsx index fc9c2bf2c..a7635a259 100644 --- a/src/components/MovieDetails/MovieRecommendations.tsx +++ b/src/components/MovieDetails/MovieRecommendations.tsx @@ -1,21 +1,20 @@ +import Header from '@app/components/Common/Header'; +import ListView from '@app/components/Common/ListView'; +import PageTitle from '@app/components/Common/PageTitle'; +import useDiscover from '@app/hooks/useDiscover'; +import Error from '@app/pages/_error'; +import type { MovieDetails } from '@server/models/Movie'; +import type { MovieResult } from '@server/models/Search'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import type { MovieDetails } from '../../../server/models/Movie'; -import type { MovieResult } from '../../../server/models/Search'; -import useDiscover from '../../hooks/useDiscover'; -import Error from '../../pages/_error'; -import Header from '../Common/Header'; -import ListView from '../Common/ListView'; -import PageTitle from '../Common/PageTitle'; const messages = defineMessages({ recommendations: 'Recommendations', }); -const MovieRecommendations: React.FC = () => { +const MovieRecommendations = () => { const intl = useIntl(); const router = useRouter(); const { data: movieData } = useSWR( diff --git a/src/components/MovieDetails/MovieSimilar.tsx b/src/components/MovieDetails/MovieSimilar.tsx index 8103f966e..5ce5ef1a1 100644 --- a/src/components/MovieDetails/MovieSimilar.tsx +++ b/src/components/MovieDetails/MovieSimilar.tsx @@ -1,21 +1,20 @@ +import Header from '@app/components/Common/Header'; +import ListView from '@app/components/Common/ListView'; +import PageTitle from '@app/components/Common/PageTitle'; +import useDiscover from '@app/hooks/useDiscover'; +import Error from '@app/pages/_error'; +import type { MovieDetails } from '@server/models/Movie'; +import type { MovieResult } from '@server/models/Search'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import type { MovieDetails } from '../../../server/models/Movie'; -import type { MovieResult } from '../../../server/models/Search'; -import useDiscover from '../../hooks/useDiscover'; -import Error from '../../pages/_error'; -import Header from '../Common/Header'; -import ListView from '../Common/ListView'; -import PageTitle from '../Common/PageTitle'; const messages = defineMessages({ similar: 'Similar Titles', }); -const MovieSimilar: React.FC = () => { +const MovieSimilar = () => { const router = useRouter(); const intl = useIntl(); const { data: movieData } = useSWR( diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 3e33c4b8a..d963585bc 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -1,3 +1,29 @@ +import RTAudFresh from '@app/assets/rt_aud_fresh.svg'; +import RTAudRotten from '@app/assets/rt_aud_rotten.svg'; +import RTFresh from '@app/assets/rt_fresh.svg'; +import RTRotten from '@app/assets/rt_rotten.svg'; +import TmdbLogo from '@app/assets/tmdb_logo.svg'; +import Button from '@app/components/Common/Button'; +import CachedImage from '@app/components/Common/CachedImage'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import type { PlayButtonLink } from '@app/components/Common/PlayButton'; +import PlayButton from '@app/components/Common/PlayButton'; +import Tooltip from '@app/components/Common/Tooltip'; +import ExternalLinkBlock from '@app/components/ExternalLinkBlock'; +import IssueModal from '@app/components/IssueModal'; +import ManageSlideOver from '@app/components/ManageSlideOver'; +import MediaSlider from '@app/components/MediaSlider'; +import PersonCard from '@app/components/PersonCard'; +import RequestButton from '@app/components/RequestButton'; +import Slider from '@app/components/Slider'; +import StatusBadge from '@app/components/StatusBadge'; +import useLocale from '@app/hooks/useLocale'; +import useSettings from '@app/hooks/useSettings'; +import { Permission, useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; +import Error from '@app/pages/_error'; +import { sortCrewPriority } from '@app/utils/creditHelpers'; import { ArrowCircleRightIcon, CloudIcon, @@ -11,44 +37,20 @@ import { ChevronDoubleDownIcon, ChevronDoubleUpIcon, } from '@heroicons/react/solid'; +import type { RTRating } from '@server/api/rottentomatoes'; +import { IssueStatus } from '@server/constants/issue'; +import { MediaStatus } from '@server/constants/media'; +import { MediaServerType } from '@server/constants/server'; +import type { MovieDetails as MovieDetailsType } from '@server/models/Movie'; import { hasFlag } from 'country-flag-icons'; import 'country-flag-icons/3x2/flags.css'; import { uniqBy } from 'lodash'; +import getConfig from 'next/config'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React, { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import type { RTRating } from '../../../server/api/rottentomatoes'; -import { IssueStatus } from '../../../server/constants/issue'; -import { MediaStatus } from '../../../server/constants/media'; -import { MediaServerType } from '../../../server/constants/server'; -import type { MovieDetails as MovieDetailsType } from '../../../server/models/Movie'; -import RTAudFresh from '../../assets/rt_aud_fresh.svg'; -import RTAudRotten from '../../assets/rt_aud_rotten.svg'; -import RTFresh from '../../assets/rt_fresh.svg'; -import RTRotten from '../../assets/rt_rotten.svg'; -import TmdbLogo from '../../assets/tmdb_logo.svg'; -import useLocale from '../../hooks/useLocale'; -import useSettings from '../../hooks/useSettings'; -import { Permission, useUser } from '../../hooks/useUser'; -import globalMessages from '../../i18n/globalMessages'; -import Error from '../../pages/_error'; -import { sortCrewPriority } from '../../utils/creditHelpers'; -import Button from '../Common/Button'; -import CachedImage from '../Common/CachedImage'; -import LoadingSpinner from '../Common/LoadingSpinner'; -import PageTitle from '../Common/PageTitle'; -import PlayButton, { PlayButtonLink } from '../Common/PlayButton'; -import ExternalLinkBlock from '../ExternalLinkBlock'; -import IssueModal from '../IssueModal'; -import ManageSlideOver from '../ManageSlideOver'; -import MediaSlider from '../MediaSlider'; -import PersonCard from '../PersonCard'; -import RequestButton from '../RequestButton'; -import Slider from '../Slider'; -import StatusBadge from '../StatusBadge'; -import getConfig from 'next/config'; const messages = defineMessages({ originaltitle: 'Original Title', @@ -78,13 +80,21 @@ const messages = defineMessages({ streamingproviders: 'Currently Streaming On', productioncountries: 'Production {countryCount, plural, one {Country} other {Countries}}', + theatricalrelease: 'Theatrical Release', + digitalrelease: 'Digital Release', + physicalrelease: 'Physical Release', + reportissue: 'Report an Issue', + managemovie: 'Manage Movie', + rtcriticsscore: 'Rotten Tomatoes Tomatometer', + rtaudiencescore: 'Rotten Tomatoes Audience Score', + tmdbuserscore: 'TMDB User Score', }); interface MovieDetailsProps { movie?: MovieDetailsType; } -const MovieDetails: React.FC = ({ movie }) => { +const MovieDetails = ({ movie }: MovieDetailsProps) => { const settings = useSettings(); const { user, hasPermission } = useUser(); const router = useRouter(); @@ -119,6 +129,32 @@ const MovieDetails: React.FC = ({ movie }) => { setShowManager(router.query.manage == '1' ? true : false); }, [router.query.manage]); + const [plexUrl, setPlexUrl] = useState(data?.mediaInfo?.mediaUrl); + const [plexUrl4k, setPlexUrl4k] = useState(data?.mediaInfo?.mediaUrl4k); + + useEffect(() => { + if (data) { + if ( + settings.currentSettings.mediaServerType === MediaServerType.PLEX && + (/iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.userAgent === 'MacIntel' && navigator.maxTouchPoints > 1)) + ) { + setPlexUrl(data.mediaInfo?.iOSPlexUrl); + setPlexUrl4k(data.mediaInfo?.iOSPlexUrl4k); + } else { + setPlexUrl(data.mediaInfo?.mediaUrl); + setPlexUrl4k(data.mediaInfo?.mediaUrl4k); + } + } + }, [ + data, + data?.mediaInfo?.iOSPlexUrl, + data?.mediaInfo?.iOSPlexUrl4k, + data?.mediaInfo?.mediaUrl, + data?.mediaInfo?.mediaUrl4k, + settings.currentSettings.mediaServerType, + ]); + if (!data && !error) { return ; } @@ -130,27 +166,32 @@ const MovieDetails: React.FC = ({ movie }) => { const showAllStudios = data.productionCompanies.length <= minStudios + 1; const mediaLinks: PlayButtonLink[] = []; - if (data.mediaInfo?.mediaUrl) { + if ( + plexUrl && + hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], { + type: 'or', + }) + ) { mediaLinks.push({ text: getAvalaibleMediaServerName(), - url: data.mediaInfo?.mediaUrl, + url: plexUrl, svg: , }); } if ( - data.mediaInfo?.mediaUrl4k && - hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], { + settings.currentSettings.movie4kEnabled && + plexUrl4k && + hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], { type: 'or', }) ) { mediaLinks.push({ text: getAvalaible4kMediaServerName(), - url: data.mediaInfo?.mediaUrl4k, + url: plexUrl4k, svg: , }); } - const trailerUrl = data.relatedVideos ?.filter((r) => r.type === 'Trailer') .sort((a, b) => a.size - b.size) @@ -315,7 +356,8 @@ const MovieDetails: React.FC = ({ movie }) => { inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0} tmdbId={data.mediaInfo?.tmdbId} mediaType="movie" - plexUrl={data.mediaInfo?.mediaUrl} + plexUrl={plexUrl} + serviceUrl={data.mediaInfo?.serviceUrl} /> {settings.currentSettings.movie4kEnabled && hasPermission( @@ -336,11 +378,12 @@ const MovieDetails: React.FC = ({ movie }) => { } tmdbId={data.mediaInfo?.tmdbId} mediaType="movie" - plexUrl={data.mediaInfo?.mediaUrl4k} + plexUrl={plexUrl} + serviceUrl={data.mediaInfo?.serviceUrl4k} /> )}
      -

      +

      {data.title}{' '} {data.releaseDate && ( @@ -384,38 +427,42 @@ const MovieDetails: React.FC = ({ movie }) => { type: 'or', } ) && ( - + + + )} {hasPermission(Permission.MANAGE_REQUESTS) && data.mediaInfo && ( - + + + )}

      @@ -489,36 +536,55 @@ const MovieDetails: React.FC = ({ movie }) => { (ratingData?.audienceRating && !!ratingData?.audienceScore)) && ( )} @@ -548,22 +614,36 @@ const MovieDetails: React.FC = ({ movie }) => { > {r.type === 3 ? ( // Theatrical - + + + ) : r.type === 4 ? ( // Digital - + + + ) : ( // Physical - - - + + + + )} {intl.formatDate(r.release_date, { diff --git a/src/components/NotificationTypeSelector/NotificationType/index.tsx b/src/components/NotificationTypeSelector/NotificationType/index.tsx index 9662ebd36..f0e6cb059 100644 --- a/src/components/NotificationTypeSelector/NotificationType/index.tsx +++ b/src/components/NotificationTypeSelector/NotificationType/index.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { hasNotificationType, NotificationItem } from '..'; +import type { NotificationItem } from '@app/components/NotificationTypeSelector'; +import { hasNotificationType } from '@app/components/NotificationTypeSelector'; interface NotificationTypeProps { option: NotificationItem; @@ -8,12 +8,12 @@ interface NotificationTypeProps { onUpdate: (newTypes: number) => void; } -const NotificationType: React.FC = ({ +const NotificationType = ({ option, currentTypes, onUpdate, parent, -}) => { +}: NotificationTypeProps) => { return ( <>
      = ({ +const NotificationTypeSelector = ({ user, enabledTypes = ALL_NOTIFICATIONS, currentTypes, onUpdate, error, -}) => { +}: NotificationTypeSelectorProps) => { const intl = useIntl(); const settings = useSettings(); const { hasPermission } = useUser({ id: user?.id }); @@ -190,6 +195,25 @@ const NotificationTypeSelector: React.FC = ({ )))); const types: NotificationItem[] = [ + { + id: 'media-auto-requested', + name: intl.formatMessage(messages.mediaautorequested), + description: intl.formatMessage(messages.mediaautorequestedDescription), + value: Notification.MEDIA_AUTO_REQUESTED, + hidden: + !user || + (!user.settings?.watchlistSyncMovies && + !user.settings?.watchlistSyncTv) || + !hasPermission( + [ + Permission.AUTO_REQUEST, + Permission.AUTO_REQUEST_MOVIE, + Permission.AUTO_REQUEST_TV, + ], + { type: 'or' } + ), + hasNotifyUser: true, + }, { id: 'media-requested', name: intl.formatMessage(messages.mediarequested), diff --git a/src/components/PWAHeader/index.tsx b/src/components/PWAHeader/index.tsx index 1c53abfb1..0dde7e421 100644 --- a/src/components/PWAHeader/index.tsx +++ b/src/components/PWAHeader/index.tsx @@ -1,12 +1,8 @@ -import React from 'react'; - interface PWAHeaderProps { applicationTitle?: string; } -const PWAHeader: React.FC = ({ - applicationTitle = 'Overseerr', -}) => { +const PWAHeader = ({ applicationTitle = 'Overseerr' }: PWAHeaderProps) => { return ( <> void; } -export const PermissionEdit: React.FC = ({ +export const PermissionEdit = ({ actingUser, currentUser, currentPermission, onUpdate, -}) => { +}: PermissionEditProps) => { const intl = useIntl(); const permissionList: PermissionItem[] = [ @@ -86,12 +99,6 @@ export const PermissionEdit: React.FC = ({ description: intl.formatMessage(messages.adminDescription), permission: Permission.ADMIN, }, - { - id: 'settings', - name: intl.formatMessage(messages.settings), - description: intl.formatMessage(messages.settingsDescription), - permission: Permission.MANAGE_SETTINGS, - }, { id: 'users', name: intl.formatMessage(messages.users), @@ -116,6 +123,18 @@ export const PermissionEdit: React.FC = ({ description: intl.formatMessage(messages.viewrequestsDescription), permission: Permission.REQUEST_VIEW, }, + { + id: 'viewrecent', + name: intl.formatMessage(messages.viewrecent), + description: intl.formatMessage(messages.viewrecentDescription), + permission: Permission.RECENT_VIEW, + }, + { + id: 'viewwatchlists', + name: intl.formatMessage(messages.viewwatchlists), + description: intl.formatMessage(messages.viewwatchlistsDescription), + permission: Permission.WATCHLIST_VIEW, + }, ], }, { @@ -175,6 +194,43 @@ export const PermissionEdit: React.FC = ({ }, ], }, + { + id: 'autorequest', + name: intl.formatMessage(messages.autorequest), + description: intl.formatMessage(messages.autorequestDescription), + permission: Permission.AUTO_REQUEST, + requires: [{ permissions: [Permission.REQUEST] }], + children: [ + { + id: 'autorequestmovies', + name: intl.formatMessage(messages.autorequestMovies), + description: intl.formatMessage( + messages.autorequestMoviesDescription + ), + permission: Permission.AUTO_REQUEST_MOVIE, + requires: [ + { + permissions: [Permission.REQUEST, Permission.REQUEST_MOVIE], + type: 'or', + }, + ], + }, + { + id: 'autorequesttv', + name: intl.formatMessage(messages.autorequestSeries), + description: intl.formatMessage( + messages.autorequestSeriesDescription + ), + permission: Permission.AUTO_REQUEST_TV, + requires: [ + { + permissions: [Permission.REQUEST, Permission.REQUEST_TV], + type: 'or', + }, + ], + }, + ], + }, { id: 'request4k', name: intl.formatMessage(messages.request4k), diff --git a/src/components/PermissionOption/index.tsx b/src/components/PermissionOption/index.tsx index 739234759..43d5128da 100644 --- a/src/components/PermissionOption/index.tsx +++ b/src/components/PermissionOption/index.tsx @@ -1,7 +1,7 @@ -import React from 'react'; -import { hasPermission } from '../../../server/lib/permissions'; -import useSettings from '../../hooks/useSettings'; -import { Permission, User } from '../../hooks/useUser'; +import useSettings from '@app/hooks/useSettings'; +import type { User } from '@app/hooks/useUser'; +import { Permission } from '@app/hooks/useUser'; +import { hasPermission } from '@server/lib/permissions'; export interface PermissionItem { id: string; @@ -26,14 +26,14 @@ interface PermissionOptionProps { onUpdate: (newPermissions: number) => void; } -const PermissionOption: React.FC = ({ +const PermissionOption = ({ option, actingUser, currentUser, currentPermission, onUpdate, parent, -}) => { +}: PermissionOptionProps) => { const settings = useSettings(); const autoApprovePermissions = [ @@ -66,14 +66,9 @@ const PermissionOption: React.FC = ({ } if ( - // Non-Admin users cannot modify the Admin permission - (actingUser && - !hasPermission(Permission.ADMIN, actingUser.permissions) && - option.permission === Permission.ADMIN) || - // Users without the Manage Settings permission cannot modify/grant that permission - (actingUser && - !hasPermission(Permission.MANAGE_SETTINGS, actingUser.permissions) && - option.permission === Permission.MANAGE_SETTINGS) + // Only the owner can modify the Admin permission + actingUser?.id !== 1 && + option.permission === Permission.ADMIN ) { disabled = true; } diff --git a/src/components/PersonCard/index.tsx b/src/components/PersonCard/index.tsx index 47fe56efc..c2b7b6422 100644 --- a/src/components/PersonCard/index.tsx +++ b/src/components/PersonCard/index.tsx @@ -1,7 +1,7 @@ +import CachedImage from '@app/components/Common/CachedImage'; import { UserCircleIcon } from '@heroicons/react/solid'; import Link from 'next/link'; -import React, { useState } from 'react'; -import CachedImage from '../Common/CachedImage'; +import { useState } from 'react'; interface PersonCardProps { personId: number; @@ -11,13 +11,13 @@ interface PersonCardProps { canExpand?: boolean; } -const PersonCard: React.FC = ({ +const PersonCard = ({ personId, name, subName, profilePath, canExpand = false, -}) => { +}: PersonCardProps) => { const [isHovered, setHovered] = useState(false); return ( diff --git a/src/components/PersonDetails/index.tsx b/src/components/PersonDetails/index.tsx index 173fc5dab..9c8173adc 100644 --- a/src/components/PersonDetails/index.tsx +++ b/src/components/PersonDetails/index.tsx @@ -1,19 +1,19 @@ +import Ellipsis from '@app/assets/ellipsis.svg'; +import CachedImage from '@app/components/Common/CachedImage'; +import ImageFader from '@app/components/Common/ImageFader'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import TitleCard from '@app/components/TitleCard'; +import globalMessages from '@app/i18n/globalMessages'; +import Error from '@app/pages/_error'; +import type { PersonCombinedCreditsResponse } from '@server/interfaces/api/personInterfaces'; +import type { PersonDetails as PersonDetailsType } from '@server/models/Person'; import { groupBy } from 'lodash'; import { useRouter } from 'next/router'; -import React, { useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import TruncateMarkup from 'react-truncate-markup'; import useSWR from 'swr'; -import type { PersonCombinedCreditsResponse } from '../../../server/interfaces/api/personInterfaces'; -import type { PersonDetails as PersonDetailsType } from '../../../server/models/Person'; -import Ellipsis from '../../assets/ellipsis.svg'; -import globalMessages from '../../i18n/globalMessages'; -import Error from '../../pages/_error'; -import CachedImage from '../Common/CachedImage'; -import ImageFader from '../Common/ImageFader'; -import LoadingSpinner from '../Common/LoadingSpinner'; -import PageTitle from '../Common/PageTitle'; -import TitleCard from '../TitleCard'; const messages = defineMessages({ birthdate: 'Born {birthdate}', @@ -24,7 +24,7 @@ const messages = defineMessages({ ascharacter: 'as {character}', }); -const PersonDetails: React.FC = () => { +const PersonDetails = () => { const intl = useIntl(); const router = useRouter(); const { data, error } = useSWR( diff --git a/src/components/PlexLoginButton/index.tsx b/src/components/PlexLoginButton/index.tsx index 550938716..c89f10213 100644 --- a/src/components/PlexLoginButton/index.tsx +++ b/src/components/PlexLoginButton/index.tsx @@ -1,8 +1,8 @@ +import globalMessages from '@app/i18n/globalMessages'; +import PlexOAuth from '@app/utils/plex'; import { LoginIcon } from '@heroicons/react/outline'; -import React, { useState } from 'react'; +import { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import globalMessages from '../../i18n/globalMessages'; -import PlexOAuth from '../../utils/plex'; const messages = defineMessages({ signinwithplex: 'Sign In', @@ -17,11 +17,11 @@ interface PlexLoginButtonProps { onError?: (message: string) => void; } -const PlexLoginButton: React.FC = ({ +const PlexLoginButton = ({ onAuthToken, onError, isProcessing, -}) => { +}: PlexLoginButtonProps) => { const intl = useIntl(); const [loading, setLoading] = useState(false); diff --git a/src/components/QuotaSelector/index.tsx b/src/components/QuotaSelector/index.tsx index 9ad39e221..7240dbc28 100644 --- a/src/components/QuotaSelector/index.tsx +++ b/src/components/QuotaSelector/index.tsx @@ -24,7 +24,7 @@ interface QuotaSelectorProps { onChange: (fieldName: string, value: number) => void; } -const QuotaSelector: React.FC = ({ +const QuotaSelector = ({ mediaType, dayFieldName, limitFieldName, @@ -34,7 +34,7 @@ const QuotaSelector: React.FC = ({ limitOverride, isDisabled = false, onChange, -}) => { +}: QuotaSelectorProps) => { const initialDays = defaultDays ?? 7; const initialLimit = defaultLimit ?? 0; const [quotaDays, setQuotaDays] = useState(initialDays); diff --git a/src/components/RegionSelector/index.tsx b/src/components/RegionSelector/index.tsx index 0c4bb2c6c..5a714c742 100644 --- a/src/components/RegionSelector/index.tsx +++ b/src/components/RegionSelector/index.tsx @@ -1,13 +1,13 @@ +import useSettings from '@app/hooks/useSettings'; import { Listbox, Transition } from '@headlessui/react'; import { CheckIcon, ChevronDownIcon } from '@heroicons/react/solid'; +import type { Region } from '@server/lib/settings'; import { hasFlag } from 'country-flag-icons'; import 'country-flag-icons/3x2/flags.css'; import { sortBy } from 'lodash'; -import React, { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import type { Region } from '../../../server/lib/settings'; -import useSettings from '../../hooks/useSettings'; const messages = defineMessages({ regionDefault: 'All Regions', @@ -21,12 +21,12 @@ interface RegionSelectorProps { onChange?: (fieldName: string, region: string) => void; } -const RegionSelector: React.FC = ({ +const RegionSelector = ({ name, value, isUserSetting = false, onChange, -}) => { +}: RegionSelectorProps) => { const { currentSettings } = useSettings(); const intl = useIntl(); const { data: regions } = useSWR('/api/v1/regions'); diff --git a/src/components/RequestBlock/index.tsx b/src/components/RequestBlock/index.tsx index d1d4ae8a6..e6a0c02bb 100644 --- a/src/components/RequestBlock/index.tsx +++ b/src/components/RequestBlock/index.tsx @@ -1,3 +1,10 @@ +import Badge from '@app/components/Common/Badge'; +import Button from '@app/components/Common/Button'; +import Tooltip from '@app/components/Common/Tooltip'; +import RequestModal from '@app/components/RequestModal'; +import useRequestOverride from '@app/hooks/useRequestOverride'; +import { useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; import { CalendarIcon, CheckIcon, @@ -7,18 +14,12 @@ import { UserIcon, XIcon, } from '@heroicons/react/solid'; +import { MediaRequestStatus } from '@server/constants/media'; +import type { MediaRequest } from '@server/entity/MediaRequest'; import axios from 'axios'; import Link from 'next/link'; -import React, { useState } from 'react'; +import { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { MediaRequestStatus } from '../../../server/constants/media'; -import type { MediaRequest } from '../../../server/entity/MediaRequest'; -import useRequestOverride from '../../hooks/useRequestOverride'; -import { useUser } from '../../hooks/useUser'; -import globalMessages from '../../i18n/globalMessages'; -import Badge from '../Common/Badge'; -import Button from '../Common/Button'; -import RequestModal from '../RequestModal'; const messages = defineMessages({ seasons: '{seasonCount, plural, one {Season} other {Seasons}}', @@ -27,6 +28,13 @@ const messages = defineMessages({ profilechanged: 'Quality Profile', rootfolder: 'Root Folder', languageprofile: 'Language Profile', + requestdate: 'Request Date', + requestedby: 'Requested By', + lastmodifiedby: 'Last Modified By', + approve: 'Approve Request', + decline: 'Decline Request', + edit: 'Edit Request', + delete: 'Delete Request', }); interface RequestBlockProps { @@ -34,7 +42,7 @@ interface RequestBlockProps { onUpdate?: () => void; } -const RequestBlock: React.FC = ({ request, onUpdate }) => { +const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => { const { user } = useUser(); const intl = useIntl(); const [isUpdating, setIsUpdating] = useState(false); @@ -83,7 +91,9 @@ const RequestBlock: React.FC = ({ request, onUpdate }) => {
      - + + + = ({ request, onUpdate }) => {
      {request.modifiedBy && (
      - + + + = ({ request, onUpdate }) => {
      {request.status === MediaRequestStatus.PENDING && ( <> - - - + + + + + + + + + )} {request.status !== MediaRequestStatus.PENDING && ( - + + + )}
      @@ -179,10 +199,17 @@ const RequestBlock: React.FC = ({ request, onUpdate }) => { {intl.formatMessage(globalMessages.pending)} )} + {request.status === MediaRequestStatus.FAILED && ( + + {intl.formatMessage(globalMessages.failed)} + + )}
      - + + + {intl.formatDate(request.createdAt, { year: 'numeric', diff --git a/src/components/RequestButton/index.tsx b/src/components/RequestButton/index.tsx index 5ba5bf5d5..f71589448 100644 --- a/src/components/RequestButton/index.tsx +++ b/src/components/RequestButton/index.tsx @@ -1,23 +1,20 @@ +import ButtonWithDropdown from '@app/components/Common/ButtonWithDropdown'; +import RequestModal from '@app/components/RequestModal'; +import useSettings from '@app/hooks/useSettings'; +import { Permission, useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; import { DownloadIcon } from '@heroicons/react/outline'; import { CheckIcon, InformationCircleIcon, XIcon, } from '@heroicons/react/solid'; +import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; +import type Media from '@server/entity/Media'; +import type { MediaRequest } from '@server/entity/MediaRequest'; import axios from 'axios'; -import React, { useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { - MediaRequestStatus, - MediaStatus, -} from '../../../server/constants/media'; -import Media from '../../../server/entity/Media'; -import { MediaRequest } from '../../../server/entity/MediaRequest'; -import useSettings from '../../hooks/useSettings'; -import { Permission, useUser } from '../../hooks/useUser'; -import globalMessages from '../../i18n/globalMessages'; -import ButtonWithDropdown from '../Common/ButtonWithDropdown'; -import RequestModal from '../RequestModal'; const messages = defineMessages({ viewrequest: 'View Request', @@ -54,14 +51,14 @@ interface RequestButtonProps { is4kShowComplete?: boolean; } -const RequestButton: React.FC = ({ +const RequestButton = ({ tmdbId, onUpdate, media, mediaType, isShowComplete = false, is4kShowComplete = false, -}) => { +}: RequestButtonProps) => { const intl = useIntl(); const settings = useSettings(); const { user, hasPermission } = useUser(); @@ -77,13 +74,13 @@ const RequestButton: React.FC = ({ (request) => request.status === MediaRequestStatus.PENDING && request.is4k ); + // Current user's pending request, or the first pending request const activeRequest = useMemo(() => { return activeRequests && activeRequests.length > 0 ? activeRequests.find((request) => request.requestedBy.id === user?.id) ?? activeRequests[0] : undefined; }, [activeRequests, user]); - const active4kRequest = useMemo(() => { return active4kRequests && active4kRequests.length > 0 ? active4kRequests.find( @@ -121,6 +118,151 @@ const RequestButton: React.FC = ({ }; const buttons: ButtonOption[] = []; + + // If there are pending requests, show request management options first + if (activeRequest || active4kRequest) { + if ( + activeRequest && + (activeRequest.requestedBy.id === user?.id || + (activeRequests?.length === 1 && + hasPermission(Permission.MANAGE_REQUESTS))) + ) { + buttons.push({ + id: 'active-request', + text: intl.formatMessage(messages.viewrequest), + action: () => { + setEditRequest(true); + setShowRequestModal(true); + }, + svg: , + }); + } + + if ( + activeRequest && + hasPermission(Permission.MANAGE_REQUESTS) && + mediaType === 'movie' + ) { + buttons.push( + { + id: 'approve-request', + text: intl.formatMessage(messages.approverequest), + action: () => { + modifyRequest(activeRequest, 'approve'); + }, + svg: , + }, + { + id: 'decline-request', + text: intl.formatMessage(messages.declinerequest), + action: () => { + modifyRequest(activeRequest, 'decline'); + }, + svg: , + } + ); + } else if ( + activeRequests && + activeRequests.length > 0 && + hasPermission(Permission.MANAGE_REQUESTS) && + mediaType === 'tv' + ) { + buttons.push( + { + id: 'approve-request-batch', + text: intl.formatMessage(messages.approverequests, { + requestCount: activeRequests.length, + }), + action: () => { + modifyRequests(activeRequests, 'approve'); + }, + svg: , + }, + { + id: 'decline-request-batch', + text: intl.formatMessage(messages.declinerequests, { + requestCount: activeRequests.length, + }), + action: () => { + modifyRequests(activeRequests, 'decline'); + }, + svg: , + } + ); + } + + if ( + active4kRequest && + (active4kRequest.requestedBy.id === user?.id || + (active4kRequests?.length === 1 && + hasPermission(Permission.MANAGE_REQUESTS))) + ) { + buttons.push({ + id: 'active-4k-request', + text: intl.formatMessage(messages.viewrequest4k), + action: () => { + setEditRequest(true); + setShowRequest4kModal(true); + }, + svg: , + }); + } + + if ( + active4kRequest && + hasPermission(Permission.MANAGE_REQUESTS) && + mediaType === 'movie' + ) { + buttons.push( + { + id: 'approve-4k-request', + text: intl.formatMessage(messages.approverequest4k), + action: () => { + modifyRequest(active4kRequest, 'approve'); + }, + svg: , + }, + { + id: 'decline-4k-request', + text: intl.formatMessage(messages.declinerequest4k), + action: () => { + modifyRequest(active4kRequest, 'decline'); + }, + svg: , + } + ); + } else if ( + active4kRequests && + active4kRequests.length > 0 && + hasPermission(Permission.MANAGE_REQUESTS) && + mediaType === 'tv' + ) { + buttons.push( + { + id: 'approve-4k-request-batch', + text: intl.formatMessage(messages.approve4krequests, { + requestCount: active4kRequests.length, + }), + action: () => { + modifyRequests(active4kRequests, 'approve'); + }, + svg: , + }, + { + id: 'decline-4k-request-batch', + text: intl.formatMessage(messages.decline4krequests, { + requestCount: active4kRequests.length, + }), + action: () => { + modifyRequests(active4kRequests, 'decline'); + }, + svg: , + } + ); + } + } + + // Standard request button if ( (!media || media.status === MediaStatus.UNKNOWN) && hasPermission( @@ -142,8 +284,28 @@ const RequestButton: React.FC = ({ }, svg: , }); + } else if ( + mediaType === 'tv' && + (!activeRequest || activeRequest.requestedBy.id !== user?.id) && + hasPermission([Permission.REQUEST, Permission.REQUEST_TV], { + type: 'or', + }) && + media && + media.status !== MediaStatus.AVAILABLE && + !isShowComplete + ) { + buttons.push({ + id: 'request-more', + text: intl.formatMessage(messages.requestmore), + action: () => { + setEditRequest(false); + setShowRequestModal(true); + }, + svg: , + }); } + // 4K request button if ( (!media || media.status4k === MediaStatus.UNKNOWN) && hasPermission( @@ -167,175 +329,7 @@ const RequestButton: React.FC = ({ }, svg: , }); - } - - if ( - activeRequest && - (activeRequest.requestedBy.id === user?.id || - (activeRequests?.length === 1 && - hasPermission(Permission.MANAGE_REQUESTS))) - ) { - buttons.push({ - id: 'active-request', - text: intl.formatMessage(messages.viewrequest), - action: () => { - setEditRequest(true); - setShowRequestModal(true); - }, - svg: , - }); - } - - if ( - active4kRequest && - (active4kRequest.requestedBy.id === user?.id || - (active4kRequests?.length === 1 && - hasPermission(Permission.MANAGE_REQUESTS))) - ) { - buttons.push({ - id: 'active-4k-request', - text: intl.formatMessage(messages.viewrequest4k), - action: () => { - setEditRequest(true); - setShowRequest4kModal(true); - }, - svg: , - }); - } - - if ( - activeRequest && - hasPermission(Permission.MANAGE_REQUESTS) && - mediaType === 'movie' - ) { - buttons.push( - { - id: 'approve-request', - text: intl.formatMessage(messages.approverequest), - action: () => { - modifyRequest(activeRequest, 'approve'); - }, - svg: , - }, - { - id: 'decline-request', - text: intl.formatMessage(messages.declinerequest), - action: () => { - modifyRequest(activeRequest, 'decline'); - }, - svg: , - } - ); - } - - if ( - activeRequests && - activeRequests.length > 0 && - hasPermission(Permission.MANAGE_REQUESTS) && - mediaType === 'tv' - ) { - buttons.push( - { - id: 'approve-request-batch', - text: intl.formatMessage(messages.approverequests, { - requestCount: activeRequests.length, - }), - action: () => { - modifyRequests(activeRequests, 'approve'); - }, - svg: , - }, - { - id: 'decline-request-batch', - text: intl.formatMessage(messages.declinerequests, { - requestCount: activeRequests.length, - }), - action: () => { - modifyRequests(activeRequests, 'decline'); - }, - svg: , - } - ); - } - - if ( - active4kRequest && - hasPermission(Permission.MANAGE_REQUESTS) && - mediaType === 'movie' - ) { - buttons.push( - { - id: 'approve-4k-request', - text: intl.formatMessage(messages.approverequest4k), - action: () => { - modifyRequest(active4kRequest, 'approve'); - }, - svg: , - }, - { - id: 'decline-4k-request', - text: intl.formatMessage(messages.declinerequest4k), - action: () => { - modifyRequest(active4kRequest, 'decline'); - }, - svg: , - } - ); - } - - if ( - active4kRequests && - active4kRequests.length > 0 && - hasPermission(Permission.MANAGE_REQUESTS) && - mediaType === 'tv' - ) { - buttons.push( - { - id: 'approve-4k-request-batch', - text: intl.formatMessage(messages.approve4krequests, { - requestCount: active4kRequests.length, - }), - action: () => { - modifyRequests(active4kRequests, 'approve'); - }, - svg: , - }, - { - id: 'decline-4k-request-batch', - text: intl.formatMessage(messages.decline4krequests, { - requestCount: active4kRequests.length, - }), - action: () => { - modifyRequests(active4kRequests, 'decline'); - }, - svg: , - } - ); - } - - if ( - mediaType === 'tv' && - (!activeRequest || activeRequest.requestedBy.id !== user?.id) && - hasPermission([Permission.REQUEST, Permission.REQUEST_TV], { - type: 'or', - }) && - media && - media.status !== MediaStatus.AVAILABLE && - media.status !== MediaStatus.UNKNOWN && - !isShowComplete - ) { - buttons.push({ - id: 'request-more', - text: intl.formatMessage(messages.requestmore), - action: () => { - setEditRequest(false); - setShowRequestModal(true); - }, - svg: , - }); - } - - if ( + } else if ( mediaType === 'tv' && (!active4kRequest || active4kRequest.requestedBy.id !== user?.id) && hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], { @@ -343,7 +337,6 @@ const RequestButton: React.FC = ({ }) && media && media.status4k !== MediaStatus.AVAILABLE && - media.status4k !== MediaStatus.UNKNOWN && !is4kShowComplete && settings.currentSettings.series4kEnabled ) { diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index 4ac1bfe9b..e59f164f3 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -1,3 +1,12 @@ +import Badge from '@app/components/Common/Badge'; +import Button from '@app/components/Common/Button'; +import CachedImage from '@app/components/Common/CachedImage'; +import Tooltip from '@app/components/Common/Tooltip'; +import RequestModal from '@app/components/RequestModal'; +import StatusBadge from '@app/components/StatusBadge'; +import { Permission, useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; +import { withProperties } from '@app/utils/typeHelpers'; import { CheckIcon, PencilIcon, @@ -5,33 +14,28 @@ import { TrashIcon, XIcon, } from '@heroicons/react/solid'; +import { MediaRequestStatus } from '@server/constants/media'; +import type { MediaRequest } from '@server/entity/MediaRequest'; +import type { MovieDetails } from '@server/models/Movie'; +import type { TvDetails } from '@server/models/Tv'; import axios from 'axios'; import Link from 'next/link'; -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useInView } from 'react-intersection-observer'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR, { mutate } from 'swr'; -import { - MediaRequestStatus, - MediaStatus, -} from '../../../server/constants/media'; -import type { MediaRequest } from '../../../server/entity/MediaRequest'; -import type { MovieDetails } from '../../../server/models/Movie'; -import type { TvDetails } from '../../../server/models/Tv'; -import { Permission, useUser } from '../../hooks/useUser'; -import globalMessages from '../../i18n/globalMessages'; -import { withProperties } from '../../utils/typeHelpers'; -import Badge from '../Common/Badge'; -import Button from '../Common/Button'; -import CachedImage from '../Common/CachedImage'; -import RequestModal from '../RequestModal'; -import StatusBadge from '../StatusBadge'; const messages = defineMessages({ seasons: '{seasonCount, plural, one {Season} other {Seasons}}', failedretry: 'Something went wrong while retrying the request.', - mediaerror: 'The associated title for this request is no longer available.', + mediaerror: '{mediaType} Not Found', + tmdbid: 'TMDB ID', + tvdbid: 'TheTVDB ID', + approverequest: 'Approve Request', + declinerequest: 'Decline Request', + editrequest: 'Edit Request', + cancelrequest: 'Cancel Request', deleterequest: 'Delete Request', }); @@ -39,7 +43,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { return (movie as MovieDetails).title !== undefined; }; -const RequestCardPlaceholder: React.FC = () => { +const RequestCardPlaceholder = () => { return (
      @@ -50,37 +54,133 @@ const RequestCardPlaceholder: React.FC = () => { }; interface RequestCardErrorProps { - mediaId?: number; + requestData?: MediaRequest; } -const RequestCardError: React.FC = ({ mediaId }) => { +const RequestCardError = ({ requestData }: RequestCardErrorProps) => { const { hasPermission } = useUser(); const intl = useIntl(); const deleteRequest = async () => { - await axios.delete(`/api/v1/media/${mediaId}`); + await axios.delete(`/api/v1/media/${requestData?.media.id}`); + mutate('/api/v1/media?filter=allavailable&take=20&sort=mediaAdded'); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); }; return ( -
      +
      -
      -
      - {intl.formatMessage(messages.mediaerror)} +
      +
      + {intl.formatMessage(messages.mediaerror, { + mediaType: intl.formatMessage( + requestData?.type + ? requestData?.type === 'movie' + ? globalMessages.movie + : globalMessages.tvshow + : globalMessages.request + ), + })}
      - {hasPermission(Permission.MANAGE_REQUESTS) && mediaId && ( - + {requestData && ( + <> + {hasPermission( + [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], + { type: 'or' } + ) && ( + + )} +
      + + {intl.formatMessage(globalMessages.status)} + + {requestData.status === MediaRequestStatus.DECLINED || + requestData.status === MediaRequestStatus.FAILED ? ( + + {requestData.status === MediaRequestStatus.DECLINED + ? intl.formatMessage(globalMessages.declined) + : intl.formatMessage(globalMessages.failed)} + + ) : ( + 0 + } + is4k={requestData.is4k} + plexUrl={ + requestData.is4k + ? requestData.media.mediaUrl4k + : requestData.media.mediaUrl + } + serviceUrl={ + requestData.is4k + ? requestData.media.serviceUrl4k + : requestData.media.serviceUrl + } + /> + )} +
      + )} +
      + {hasPermission(Permission.MANAGE_REQUESTS) && + requestData?.media.id && ( + <> + + + + + + )} +
      @@ -93,7 +193,7 @@ interface RequestCardProps { onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void; } -const RequestCard: React.FC = ({ request, onTitleData }) => { +const RequestCard = ({ request, onTitleData }: RequestCardProps) => { const { ref, inView } = useInView({ triggerOnce: true, }); @@ -168,7 +268,7 @@ const RequestCard: React.FC = ({ request, onTitleData }) => { } if (!title || !requestData) { - return ; + return ; } return ( @@ -185,7 +285,10 @@ const RequestCard: React.FC = ({ request, onTitleData }) => { setShowEditModal(false); }} /> -
      +
      {title.backdropPath && (
      = ({ request, onTitleData }) => { />
      )} -
      +
      {(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice( 0, @@ -275,8 +381,7 @@ const RequestCard: React.FC = ({ request, onTitleData }) => { {intl.formatMessage(globalMessages.declined)} - ) : requestData.media[requestData.is4k ? 'status4k' : 'status'] === - MediaStatus.UNKNOWN ? ( + ) : requestData.status === MediaRequestStatus.FAILED ? ( = ({ request, onTitleData }) => { tmdbId={requestData.media.tmdbId} mediaType={requestData.type} plexUrl={ - requestData.media[ - requestData.is4k ? 'mediaUrl4k' : 'mediaUrl' - ] + requestData.is4k + ? requestData.media.mediaUrl4k + : requestData.media.mediaUrl + } + serviceUrl={ + requestData.is4k + ? requestData.media.serviceUrl4k + : requestData.media.serviceUrl } /> )}
      - {requestData.media[requestData.is4k ? 'status4k' : 'status'] === - MediaStatus.UNKNOWN && - requestData.status !== MediaRequestStatus.DECLINED && + {requestData.status === MediaRequestStatus.FAILED && hasPermission(Permission.MANAGE_REQUESTS) && ( - +
      + + + + +
      +
      + + + + +
      )} {requestData.status === MediaRequestStatus.PENDING && @@ -356,33 +490,54 @@ const RequestCard: React.FC = ({ request, onTitleData }) => { requestData.requestedBy.id === user?.id && (requestData.type === 'tv' || hasPermission(Permission.REQUEST_ADVANCED)) && ( - +
      + {!hasPermission(Permission.MANAGE_REQUESTS) && ( + + )} + + + +
      )} {requestData.status === MediaRequestStatus.PENDING && !hasPermission(Permission.MANAGE_REQUESTS) && requestData.requestedBy.id === user?.id && ( - +
      + + + + +
      )}
      diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 6c98281ec..877d85396 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -1,3 +1,11 @@ +import Badge from '@app/components/Common/Badge'; +import Button from '@app/components/Common/Button'; +import CachedImage from '@app/components/Common/CachedImage'; +import ConfirmButton from '@app/components/Common/ConfirmButton'; +import RequestModal from '@app/components/RequestModal'; +import StatusBadge from '@app/components/StatusBadge'; +import { Permission, useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; import { CheckIcon, PencilIcon, @@ -5,28 +13,17 @@ import { TrashIcon, XIcon, } from '@heroicons/react/solid'; +import { MediaRequestStatus } from '@server/constants/media'; +import type { MediaRequest } from '@server/entity/MediaRequest'; +import type { MovieDetails } from '@server/models/Movie'; +import type { TvDetails } from '@server/models/Tv'; import axios from 'axios'; import Link from 'next/link'; -import React, { useState } from 'react'; +import { useState } from 'react'; import { useInView } from 'react-intersection-observer'; import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; -import { - MediaRequestStatus, - MediaStatus, -} from '../../../../server/constants/media'; -import type { MediaRequest } from '../../../../server/entity/MediaRequest'; -import type { MovieDetails } from '../../../../server/models/Movie'; -import type { TvDetails } from '../../../../server/models/Tv'; -import { Permission, useUser } from '../../../hooks/useUser'; -import globalMessages from '../../../i18n/globalMessages'; -import Badge from '../../Common/Badge'; -import Button from '../../Common/Button'; -import CachedImage from '../../Common/CachedImage'; -import ConfirmButton from '../../Common/ConfirmButton'; -import RequestModal from '../../RequestModal'; -import StatusBadge from '../../StatusBadge'; const messages = defineMessages({ seasons: '{seasonCount, plural, one {Season} other {Seasons}}', @@ -35,50 +32,227 @@ const messages = defineMessages({ requesteddate: 'Requested', modified: 'Modified', modifieduserdate: '{date} by {user}', - mediaerror: 'The associated title for this request is no longer available.', + mediaerror: '{mediaType} Not Found', editrequest: 'Edit Request', deleterequest: 'Delete Request', cancelRequest: 'Cancel Request', + tmdbid: 'TMDB ID', + tvdbid: 'TheTVDB ID', }); const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { return (movie as MovieDetails).title !== undefined; }; -interface RequestItemErroProps { - mediaId?: number; +interface RequestItemErrorProps { + requestData?: MediaRequest; revalidateList: () => void; } -const RequestItemError: React.FC = ({ - mediaId, +const RequestItemError = ({ + requestData, revalidateList, -}) => { +}: RequestItemErrorProps) => { const intl = useIntl(); const { hasPermission } = useUser(); const deleteRequest = async () => { - await axios.delete(`/api/v1/media/${mediaId}`); + await axios.delete(`/api/v1/media/${requestData?.media.id}`); revalidateList(); }; return ( -
      - - {intl.formatMessage(messages.mediaerror)} - - {hasPermission(Permission.MANAGE_REQUESTS) && mediaId && ( -
      +
      +
      +
      +
      + {intl.formatMessage(messages.mediaerror, { + mediaType: intl.formatMessage( + requestData?.type + ? requestData?.type === 'movie' + ? globalMessages.movie + : globalMessages.tvshow + : globalMessages.request + ), + })} +
      + {requestData && hasPermission(Permission.MANAGE_REQUESTS) && ( + <> +
      + + {intl.formatMessage(messages.tmdbid)} + + + {requestData.media.tmdbId} + +
      + {requestData.media.tvdbId && ( +
      + + {intl.formatMessage(messages.tvdbid)} + + + {requestData?.media.tvdbId} + +
      + )} + + )} +
      +
      + {requestData && ( + <> +
      + + {intl.formatMessage(globalMessages.status)} + + {requestData.status === MediaRequestStatus.DECLINED || + requestData.status === MediaRequestStatus.FAILED ? ( + + {requestData.status === MediaRequestStatus.DECLINED + ? intl.formatMessage(globalMessages.declined) + : intl.formatMessage(globalMessages.failed)} + + ) : ( + 0 + } + is4k={requestData.is4k} + plexUrl={ + requestData.is4k + ? requestData.media.mediaUrl4k + : requestData.media.mediaUrl + } + serviceUrl={ + requestData.is4k + ? requestData.media.serviceUrl4k + : requestData.media.serviceUrl + } + /> + )} +
      +
      + {hasPermission( + [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], + { type: 'or' } + ) ? ( + <> + + {intl.formatMessage(messages.requested)} + + + {intl.formatMessage(messages.modifieduserdate, { + date: ( + + ), + user: ( + + + + + {requestData.requestedBy.displayName} + + + + ), + })} + + + ) : ( + <> + + {intl.formatMessage(messages.requesteddate)} + + + + + + )} +
      + {requestData.modifiedBy && ( +
      + + {intl.formatMessage(messages.modified)} + + + {intl.formatMessage(messages.modifieduserdate, { + date: ( + + ), + user: ( + + + + + {requestData.modifiedBy.displayName} + + + + ), + })} + +
      + )} + + )} +
      +
      +
      + {hasPermission(Permission.MANAGE_REQUESTS) && requestData?.media.id && ( -
      - )} + )} +
      ); }; @@ -88,10 +262,7 @@ interface RequestItemProps { revalidateList: () => void; } -const RequestItem: React.FC = ({ - request, - revalidateList, -}) => { +const RequestItem = ({ request, revalidateList }: RequestItemProps) => { const { ref, inView } = useInView({ triggerOnce: true, }); @@ -157,7 +328,7 @@ const RequestItem: React.FC = ({ if (!title || !requestData) { return ( ); @@ -276,9 +447,7 @@ const RequestItem: React.FC = ({ {intl.formatMessage(globalMessages.declined)} - ) : requestData.media[ - requestData.is4k ? 'status4k' : 'status' - ] === MediaStatus.UNKNOWN ? ( + ) : requestData.status === MediaRequestStatus.FAILED ? ( = ({ tmdbId={requestData.media.tmdbId} mediaType={requestData.type} plexUrl={ - requestData.media[ - requestData.is4k ? 'mediaUrl4k' : 'mediaUrl' - ] + requestData.is4k + ? requestData.media.mediaUrl4k + : requestData.media.mediaUrl + } + serviceUrl={ + requestData.is4k + ? requestData.media.serviceUrl4k + : requestData.media.serviceUrl } /> )} @@ -405,9 +579,7 @@ const RequestItem: React.FC = ({
      - {requestData.media[requestData.is4k ? 'status4k' : 'status'] === - MediaStatus.UNKNOWN && - requestData.status !== MediaRequestStatus.DECLINED && + {requestData.status === MediaRequestStatus.FAILED && hasPermission(Permission.MANAGE_REQUESTS) && (