diff --git a/.all-contributorsrc b/.all-contributorsrc index a230a468..3cf5e765 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 7d669c86..21a5da86 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 b1c6f4b9..5af484c5 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 44215bde..d2026ee7 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 00000000..ecd260dd --- /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 5d104c7c..35ae768b 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 66a1ac47..8890dcae 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 00000000..bf00e04d --- /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 41a0481f..70a5d6f2 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 00000000..c735fffa --- /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 80a16c64..8dc1918f 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 26aca34b..45da7ba6 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 a016f6d4..96e67c8a 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 8f3ed32c..851ba472 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 f0228b6b..39e0534f 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 00000000..07b0c8b1 --- /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 00000000..bb7b661b --- /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 00000000..3489061b --- /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 00000000..1c955417 --- /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 00000000..1d3ecf3f --- /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 00000000..3717f65b --- /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 00000000..5b4bd049 --- /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 00000000..e7f5727b --- /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 00000000..9cc38d88 --- /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 00000000..503bd23f --- /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 00000000..896cef74 --- /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 00000000..e1afafe7 --- /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 00000000..7a7697ca --- /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 00000000..85706761 --- /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 00000000..1b6425b8 --- /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 7ff2bcab..e1bacfc6 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 56a17094..c638e863 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 37a5c048..cd2ada31 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 00000000..6908488f --- /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 40d899f7..2e680073 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 551f7dd9..e69b4259 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 7d5bb637..05620696 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 00000000..bc68da3a --- /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 20eb2f60..740f6725 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 2a1d9495..cc1e429f 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 a2a71b41..86539903 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 73278387..90aae485 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 1733a85a..76ee6618 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 b9b00e10..e190b7b9 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 9e455933..2b8ec4cb 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 7305baf0..1637a8d8 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 7440d278..a5b9c1e8 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 bb7f3723..0e5e0707 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 b5060c03..ea05b8ab 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 d9ef9e02..de2bf834 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 4122f079..a6839298 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 d8e05c56..fae96967 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 e0cadeef..42262aa1 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 f7f82115..eefbc11f 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 77f9c760..44a83d97 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 f499406c..f9eeef50 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 e7462c19..ddf851a6 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 7fa6dc67..b5f78110 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 08397b12..771c382d 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 3f4e5503..615e789b 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 db90e55d..89cb7426 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 bd17f195..e5b3643c 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 d17716d2..263d859a 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 19d3468c..c52ad0c6 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 5373cb58..32be891e 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 ca39515b..89863cb0 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 1188f24c..3b430b0b 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 c486a1b4..bafd15b1 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 e5f56482..2ac75c5e 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 d0a0ff9f..e54f0070 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 23843d92..85c8dcc5 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 181d540d..5743e337 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 7782a05a..e8146662 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 c62e189d..4aef968f 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 1274d6a8..c38892ae 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 c067a7d5..dd320ea3 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 edfa1262..d2b0b165 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 32120035..67a278bf 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 cbed472f..59c5b4aa 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 ecd54ce7..d07caac4 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 0269e260..885b038c 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 b7bc1919..eed4fda9 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 f8364c3f..d8deb1bd 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 ca10c269..9447cda3 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 3450a3c2..7d706212 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 ba2bf5e5..461cd37f 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 c87d9496..275a77e8 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 b8111d02..71aea8fe 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 95160d38..4a4a90d8 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 f76ea92b..f0f3db7e 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 cd8dbd76..73e4d9b2 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 5f47b9d9..bc299d7b 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 044f74ec..3256c948 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 c625f512..be9ee3ae 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 53fe864c..29e2fcf1 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 00000000..46147f3f --- /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 4f736e4a..d5809a0e 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 68869222..326d460d 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 73640565..db71471a 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 bcff6f60..059c6bf5 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 341bc00b..3b1ae070 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 97baa861..f602ea7f 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 e2aa8865..622a2c90 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 fba7af7f..e5ab0250 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 6a109e4d..d54c450e 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 2cd5415e..50056892 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 0ece00f4..88b0ae60 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 a313bf13..5502b9c0 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 25e42a74..d6574d39 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 cacea059..6a36f29a 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 355384a0..5a5b6553 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 f9865c8f..92ab4d4b 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 7d191d10..55a20a39 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 fa89d81b..7694f4e4 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 4d4a973e..fbe85339 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 d33df4ee..69060a0c 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 5e480d48..6e2598ab 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 b88e0dca..6940d4ad 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 63292690..62b39d65 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 d498a8b1..9e676182 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 79cd061b..9dd9288e 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 539221d1..97070599 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 9842bca7..ba182b03 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 cccdae2f..50de959b 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 0c6116f9..ebcf8d89 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 8934866f..c29cef6d 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 00000000..c0d0e947 --- /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 00000000..8580bb4e --- /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 9cc4f378..20a3c715 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 ac19ce7e..a216b743 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 087ab1c7..998585ee 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 73427a37..6ab696fe 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 b596b1d2..7f809cbf 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 49e2305c..30b40d98 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 81ba519f..4b22943e 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 aa894873..d58b0357 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 ea78bf03..b39a8332 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 e2866638..9561e171 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 07cf3277..6349bb74 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 c54bce5b..85e41aaa 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 429b2010..8f93116c 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 98474c78..f11cead8 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 5093ae46..7f5d6223 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 cd269f4e..9c9d96a8 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 3f26a393..1152bce3 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 862ab374..b77d58c9 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 7ebff760..fc83839b 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 5a337237..5a38555c 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 a33bfcdb..c2b0a6f5 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 da5a5bb3..358d0700 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 201e7afe..d45e4062 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 5811fc05..a875ca1f 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 a05311a2..9b9a11ec 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 00000000..7caede41 --- /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 1b1b7b55..cb95ba00 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 b593095c..eb402041 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 01752b0d..eecfe6f3 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 d245100d..ec4b9004 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 ee7fd972..7b82477a 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 923d4708..d01a08a9 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 00000000..4684d783 --- /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 00000000..387ec5ce --- /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 04070244..507ece8c 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 0a7099cc..3b693643 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 00000000..054149f8 --- /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 00000000..fb9268f6 --- /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 fce97bd5..21c3dbae 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 839f019a..52bd8a26 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 67e883fe..49187bd0 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 e9789c70..8ffb4a25 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 33e55ab7..47ce6586 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 f1083e5b..d3f96ae9 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 6edb4a11..be6815b9 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 df3c6572..1f5756cb 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 b7c88ddd..1653a457 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 5f68376c..a5717241 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 a4b91723..32057ed1 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 0c2a0e4e..6f09f768 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 eb8f368b..3c16bdcf 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 f6bc2ccb..99900ac9 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 84c72822..9a9bc054 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 0cc9c2e0..2006e9df 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 14268e42..1cc43b05 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 fc9c2bf2..a7635a25 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 8103f966..5ce5ef1a 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 3e33c4b8..d963585b 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 9662ebd3..f0e6cb05 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 1c53abfb..0dde7e42 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 73923475..43d5128d 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 47fe56ef..c2b7b642 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 173fc5da..9c8173ad 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 55093871..c89f1021 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 9ad39e22..7240dbc2 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 0c4bb2c6..5a714c74 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 d1d4ae8a..e6a0c02b 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 5ba5bf5d..f7158944 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 4ac1bfe9..e59f164f 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 6c98281e..877d8539 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) && (