diff --git a/.all-contributorsrc b/.all-contributorsrc index a230a4685..3cf5e765c 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -665,6 +665,78 @@ "contributions": [ "translation" ] + }, + { + "login": "sambartik", + "name": "Samuel Bartík", + "avatar_url": "https://avatars.githubusercontent.com/u/63553146?v=4", + "profile": "https://github.com/sambartik", + "contributions": [ + "code" + ] + }, + { + "login": "frank-cywong", + "name": "Chun Yeung Wong", + "avatar_url": "https://avatars.githubusercontent.com/u/90653148?v=4", + "profile": "https://github.com/frank-cywong", + "contributions": [ + "code" + ] + }, + { + "login": "TheMeanCanEHdian", + "name": "TheMeanCanEHdian", + "avatar_url": "https://avatars.githubusercontent.com/u/16025103?v=4", + "profile": "https://github.com/TheMeanCanEHdian", + "contributions": [ + "code" + ] + }, + { + "login": "Gylesie", + "name": "Gylesie", + "avatar_url": "https://avatars.githubusercontent.com/u/86306812?v=4", + "profile": "https://github.com/Gylesie", + "contributions": [ + "code" + ] + }, + { + "login": "Fhd-pro", + "name": "Fhd-pro", + "avatar_url": "https://avatars.githubusercontent.com/u/82862079?v=4", + "profile": "https://github.com/Fhd-pro", + "contributions": [ + "translation" + ] + }, + { + "login": "PovilasID", + "name": "PovilasID", + "avatar_url": "https://avatars.githubusercontent.com/u/396243?v=4", + "profile": "https://github.com/PovilasID", + "contributions": [ + "translation" + ] + }, + { + "login": "byakurau", + "name": "byakurau", + "avatar_url": "https://avatars.githubusercontent.com/u/1811683?v=4", + "profile": "https://github.com/byakurau", + "contributions": [ + "translation" + ] + }, + { + "login": "miknii", + "name": "miknii", + "avatar_url": "https://avatars.githubusercontent.com/u/109232569?v=4", + "profile": "https://github.com/miknii", + "contributions": [ + "translation" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", @@ -673,5 +745,5 @@ "projectOwner": "sct", "repoType": "github", "repoHost": "https://github.com", - "skipCi": true + "skipCi": false } diff --git a/.dockerignore b/.dockerignore index 7d669c86d..21a5da869 100644 --- a/.dockerignore +++ b/.dockerignore @@ -26,3 +26,4 @@ public/os_logo_filled.png public/preview.jpg snap stylelint.config.js +cypress diff --git a/.eslintrc.js b/.eslintrc.js index b1c6f4b9f..5af484c53 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -7,6 +7,7 @@ module.exports = { 'plugin:jsx-a11y/recommended', 'plugin:react/recommended', 'plugin:react-hooks/recommended', + 'plugin:react/jsx-runtime', 'prettier', ], parserOptions: { @@ -26,11 +27,21 @@ module.exports = { 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'warn', '@typescript-eslint/explicit-function-return-type': 'off', - 'prettier/prettier': ['error', { endOfLine: 'auto' }], 'formatjs/no-offset': 'error', 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': ['error'], + '@typescript-eslint/array-type': ['error', { default: 'array' }], 'jsx-a11y/no-onchange': 'off', + '@typescript-eslint/consistent-type-imports': [ + 'error', + { + prefer: 'type-imports', + }, + ], + 'no-relative-import-paths/no-relative-import-paths': [ + 'error', + { allowSameFolder: true }, + ], }, overrides: [ { @@ -40,7 +51,7 @@ module.exports = { }, }, ], - plugins: ['jsx-a11y', 'prettier', 'react-hooks', 'formatjs'], + plugins: ['jsx-a11y', 'react-hooks', 'formatjs', 'no-relative-import-paths'], settings: { react: { pragma: 'React', diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44215bded..d2026ee71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: Jellyseerr CI on: pull_request: branches: - - "*" + - '*' push: branches: - develop @@ -13,16 +13,18 @@ jobs: name: Lint & Test Build if: github.event_name == 'pull_request' runs-on: ubuntu-20.04 - container: node:16.14-alpine + container: node:16.17-alpine steps: - name: Checkout uses: actions/checkout@v3 - name: Install dependencies env: - HUSKY_SKIP_INSTALL: 1 + HUSKY: 0 run: yarn - name: Lint run: yarn lint + - name: Formatting + run: yarn format:check - name: Build run: yarn build @@ -34,23 +36,29 @@ jobs: - name: Checkout uses: actions/checkout@v3 - name: Set up QEMU - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v2 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v2 - name: Cache Docker layers - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ github.sha }} restore-keys: | ${{ runner.os }}-buildx- - name: Log in to Docker Hub - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} + - name: Log in to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v3 with: context: . file: ./Dockerfile @@ -77,7 +85,7 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Get Build Job Status - uses: technote-space/workflow-conclusion-action@v2 + uses: technote-space/workflow-conclusion-action@v3 - name: Combine Job Status id: status run: | diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml new file mode 100644 index 000000000..ecd260dd5 --- /dev/null +++ b/.github/workflows/cypress.yml @@ -0,0 +1,30 @@ +name: Cypress Tests + +on: + pull_request: + branches: + - '*' + push: + branches: + - develop + +jobs: + cypress-run: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Cypress run + uses: cypress-io/github-action@v4 + with: + build: yarn cypress:build + start: yarn start + wait-on: 'http://localhost:5055' + record: true + env: + CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WITH_MIGRATIONS: true + # Fix test titles in cypress dashboard + COMMIT_INFO_MESSAGE: ${{github.event.pull_request.title}} + COMMIT_INFO_SHA: ${{github.event.pull_request.head.sha}} diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 5d104c7c8..35ae768b7 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -3,7 +3,7 @@ name: Jellyseerr Preview on: push: tags: - - "preview-*" + - 'preview-*' jobs: build_and_push: @@ -16,16 +16,16 @@ jobs: id: get_version run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/} - name: Set up QEMU - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v2 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v2 - name: Log in to Docker Hub - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} - name: Build and push - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v3 with: context: . file: ./Dockerfile diff --git a/.github/workflows/private_registery_push.yml b/.github/workflows/private_registery_push.yml deleted file mode 100644 index 376223c77..000000000 --- a/.github/workflows/private_registery_push.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: 'create docker image on pull request and push to private registery' - -on: - pull_request: - branches: - - develop - workflow_dispatch: - -jobs: - build-image: - runs-on: self-hosted - steps: - - - name: Checkout - uses: actions/checkout@v2 - - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - - - name: Login to private registery - uses: docker/login-action@v2.0.0 - with: - registry: ${{ secrets.REGISTRY_URL }} - username: ${{ secrets.REGISTRY_USERNAME }} - password: ${{ secrets.REGISTRY_PASSWORD }} - - - - name: Build and push - uses: docker/build-push-action@v2 - with: - context: ./ - file: ./Dockerfile - builder: ${{ steps.buildx.outputs.name }} - push: true - tags: '${{ secrets.REGISTRY_URL }}/fallenbagel/jellyseerr:${{ github.sha }}' - cache-from: 'type=registry,ref=${{ secrets.REGISTRY_URL }}/fallenbagel/jellyseerr:buildcache' - cache-to: 'type=registry,ref=${{ secrets.REGISTRY_URL }}/fallenbagel/jellyseerr:buildcache,mode=max' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 66a1ac47f..8890dcae3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ on: workflow_dispatch jobs: semantic-release: name: Tag and release latest version - runs-on: self-hosted + runs-on: ubuntu-20.04 env: HUSKY: 0 steps: @@ -18,16 +18,14 @@ jobs: with: node-version: 16 - name: Set up QEMU - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v2 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v2 - name: Log in to Docker Hub - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} - - name: Install Yarn - run: npm install -g yarn - name: Install dependencies run: yarn - name: Release @@ -37,6 +35,60 @@ jobs: DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} run: npx semantic-release + build-snap: + name: Build Snap Package (${{ matrix.architecture }}) + needs: semantic-release + runs-on: ubuntu-20.04 + strategy: + fail-fast: false + matrix: + architecture: + - amd64 + - arm64 + - armhf + steps: + - name: Checkout Code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Switch to master branch + run: git checkout master + - name: Pull latest changes + run: git pull + - name: Prepare + id: prepare + run: | + git fetch --prune --tags + if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then + echo ::set-output name=RELEASE::stable + else + echo ::set-output name=RELEASE::edge + fi + - name: Set Up QEMU + uses: docker/setup-qemu-action@v1 + with: + image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde + - name: Build Snap Package + uses: diddlesnaps/snapcraft-multiarch-action@v1 + id: build + with: + architecture: ${{ matrix.architecture }} + - name: Upload Snap Package + uses: actions/upload-artifact@v2 + with: + name: overseerr-snap-package-${{ matrix.architecture }} + path: ${{ steps.build.outputs.snap }} + - name: Review Snap Package + uses: diddlesnaps/snapcraft-review-tools-action@v1 + with: + snap: ${{ steps.build.outputs.snap }} + - name: Publish Snap Package + uses: snapcore/action-publish@v1 + with: + store_login: ${{ secrets.SNAP_LOGIN }} + snap: ${{ steps.build.outputs.snap }} + release: ${{ steps.prepare.outputs.RELEASE }} + discord: name: Send Discord Notification needs: semantic-release @@ -44,7 +96,7 @@ jobs: runs-on: self-hosted steps: - name: Get Build Job Status - uses: technote-space/workflow-conclusion-action@v2 + uses: technote-space/workflow-conclusion-action@v3 - name: Combine Job Status id: status run: | diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml new file mode 100644 index 000000000..bf00e04d7 --- /dev/null +++ b/.github/workflows/snap.yaml @@ -0,0 +1,88 @@ +name: Publish Snap + +on: + push: + branches: + - develop + +jobs: + jobs: + name: Job Check + runs-on: ubuntu-20.04 + if: "!contains(github.event.head_commit.message, '[skip ci]')" + steps: + - name: Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.10.0 + with: + access_token: ${{ secrets.GITHUB_TOKEN }} + + build-snap: + name: Build Snap Package (${{ matrix.architecture }}) + needs: jobs + runs-on: ubuntu-20.04 + strategy: + fail-fast: false + matrix: + architecture: + - amd64 + - arm64 + - armhf + steps: + - name: Checkout Code + uses: actions/checkout@v3 + - name: Prepare + id: prepare + run: | + git fetch --prune --unshallow --tags + if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then + echo ::set-output name=RELEASE::stable + else + echo ::set-output name=RELEASE::edge + fi + - name: Set Up QEMU + uses: docker/setup-qemu-action@v2 + - name: Build Snap Package + uses: diddlesnaps/snapcraft-multiarch-action@v1 + id: build + with: + architecture: ${{ matrix.architecture }} + - name: Upload Snap Package + uses: actions/upload-artifact@v3 + with: + name: overseerr-snap-package-${{ matrix.architecture }} + path: ${{ steps.build.outputs.snap }} + - name: Review Snap Package + uses: diddlesnaps/snapcraft-review-tools-action@v1 + with: + snap: ${{ steps.build.outputs.snap }} + - name: Publish Snap Package + uses: snapcore/action-publish@v1 + with: + store_login: ${{ secrets.SNAP_LOGIN }} + snap: ${{ steps.build.outputs.snap }} + release: ${{ steps.prepare.outputs.RELEASE }} + + discord: + name: Send Discord Notification + needs: build-snap + if: always() && !contains(github.event.head_commit.message, '[skip ci]') + runs-on: ubuntu-20.04 + steps: + - name: Get Build Job Status + uses: technote-space/workflow-conclusion-action@v3 + - name: Combine Job Status + id: status + run: | + failures=(neutral, skipped, timed_out, action_required) + if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then + echo ::set-output name=status::failure + else + echo ::set-output name=status::$WORKFLOW_CONCLUSION + fi + - name: Post Status to Discord + uses: sarisia/actions-status-discord@v1 + with: + webhook: ${{ secrets.DISCORD_WEBHOOK }} + status: ${{ steps.status.outputs.status }} + title: ${{ github.workflow }} + nofail: true diff --git a/.gitignore b/.gitignore index 41a0481fd..70a5d6f2f 100644 --- a/.gitignore +++ b/.gitignore @@ -54,5 +54,16 @@ config/db/db.sqlite3-journal # VS Code .vscode/launch.json +# Cypress +cypress.env.json +cypress/videos +cypress/screenshots + +# ESLint +.eslintcache + +# TS Build Info +tsconfig.tsbuildinfo + # Webstorm .idea diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 000000000..c735fffac --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,5 @@ +module.exports = { + plugins: [require('./merged-prettier-plugin.js')], + singleQuote: true, + trailingComma: 'es5', +}; diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 80a16c644..8dc1918fb 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -11,9 +11,6 @@ // https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode "esbenp.prettier-vscode", - // https://marketplace.visualstudio.com/items?itemName=eg2.vscode-npm-script - "eg2.vscode-npm-script", - // https://marketplace.visualstudio.com/items?itemName=Orta.vscode-jest "Orta.vscode-jest", diff --git a/.vscode/settings.json b/.vscode/settings.json index 26aca34b8..45da7ba67 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,8 +15,6 @@ "database": "./config/db/db.sqlite3" } ], - "editor.codeActionsOnSave": { - "source.organizeImports": true - }, - "editor.formatOnSave": true + "editor.formatOnSave": true, + "typescript.preferences.importModuleSpecifier": "non-relative" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 00d4a12ed..0c289e5fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,210 +1,5 @@ # [1.1.1](https://github.com/fallenbagel/jellyseerr/compare/v1.1.0...v1.1.1) (2022-06-20) - -### Bug Fixes - -* conditional media server name for 4k url to add emby to tvdetails ([ddd773c](https://github.com/fallenbagel/jellyseerr/commit/ddd773c03ff61654490644dec21f406d03374b3d)) -* don't show 0 playcount in slideover ([dec4062](https://github.com/fallenbagel/jellyseerr/commit/dec4062cdcecbe297f72364ede6a000b863117f4)) -* fix mediaServerType not set for Plex which leads to Plex users seeing Jellyfin settings ([94ade93](https://github.com/fallenbagel/jellyseerr/commit/94ade93e16f02b372dafd2765bea475117431975)) -* fixes jellyfin forgot password and adds emby support to the forgot password link ([0259975](https://github.com/fallenbagel/jellyseerr/commit/02599754026e6a66662f753bb6b6117dfabb5f9a)), closes [#99](https://github.com/fallenbagel/jellyseerr/issues/99) -* hide plex guid cache settings from ui when running in jellyfin/emby mode ([7450138](https://github.com/fallenbagel/jellyseerr/commit/7450138ac12640797952c1a2d5e1e111d17a11e1)) -* **import all:** fis for import all ([29478fc](https://github.com/fallenbagel/jellyseerr/commit/29478fc19534589db37499f1cdcc21ea4d389a74)) -* **jellyfin:** ignore additional items with virtual location type ([c811548](https://github.com/fallenbagel/jellyseerr/commit/c81154800fd7dc48fe890f4dd57ff33cbab973bb)) -* **jellyfinimportmodal:** fix for importing all jellyfin users ([a483ca9](https://github.com/fallenbagel/jellyseerr/commit/a483ca9837e12e2385d0e2407e52d6c64ae435e2)) -* **jellyfin:** sync errors ([d1dbd6e](https://github.com/fallenbagel/jellyseerr/commit/d1dbd6e3b9b1134e06150fc5eb21f729f64c0955)) -* manual browser refresh would redirect to home on search page ([9ded45f](https://github.com/fallenbagel/jellyseerr/commit/9ded45fef80b4a7e0be237fbe0301629f862fff9)) -* manual browser refresh would redirect to home on search page ([#2692](https://github.com/fallenbagel/jellyseerr/issues/2692)) ([b287839](https://github.com/fallenbagel/jellyseerr/commit/b2878390b486e338151f26a2354711147012f88e)), closes [#2683](https://github.com/fallenbagel/jellyseerr/issues/2683) -* only show mediaserver settings for current active mediaserver ([739f5f9](https://github.com/fallenbagel/jellyseerr/commit/739f5f9c9ade8a1680bcb374f6c9e919a9e1426c)) -* **recommendations:** fixed recommendations page causing infinite network requests to tmdb api ([4f972be](https://github.com/fallenbagel/jellyseerr/commit/4f972be8584e48f544268aef9d1d05769ba2e38e)) -* **recommendations:** only load more titles if there can be more than 40 ([#2749](https://github.com/fallenbagel/jellyseerr/issues/2749)) ([14519ef](https://github.com/fallenbagel/jellyseerr/commit/14519ef5559038b0d9d037a2bdc5d98e63c9db6f)), closes [#2710](https://github.com/fallenbagel/jellyseerr/issues/2710) -* remove internal Overseerr sponsor link, this is remaining on the main github page instead ([4b7bdd3](https://github.com/fallenbagel/jellyseerr/commit/4b7bdd3d7d608fe0bf52f494766fd7c40bede859)) -* **scan:** ignore virtual seasons ([6574e18](https://github.com/fallenbagel/jellyseerr/commit/6574e18516201bc11b5f0c422bf6b432c722e067)), closes [#119](https://github.com/fallenbagel/jellyseerr/issues/119) -* **search:** use correct param to filter movies by year ([b07f703](https://github.com/fallenbagel/jellyseerr/commit/b07f7032ad89ccb359f3a6a4f4508de6b59ec393)) -* **search:** use correct param to filter movies by year ([#2727](https://github.com/fallenbagel/jellyseerr/issues/2727)) ([1054b4e](https://github.com/fallenbagel/jellyseerr/commit/1054b4e2d7262d841fa83cde624f1138ad7bd23a)) -* **setup&login:** fix a description error in the manual scan in setup and add emby to login page ([8810c20](https://github.com/fallenbagel/jellyseerr/commit/8810c20fc18a55c2f6768ddc40830a8494946072)) -* **ui:** don't show 0 playcount in slideover ([#2714](https://github.com/fallenbagel/jellyseerr/issues/2714)) ([29be659](https://github.com/fallenbagel/jellyseerr/commit/29be6595125017700eccb34d33a0e852f23c97ba)) -* **ui:** fix Avatar being broken when setup using internal ip ([01e81a7](https://github.com/fallenbagel/jellyseerr/commit/01e81a73a3ae3c4692d0b9b68dc27fe1a54b1a1d)), closes [#110](https://github.com/fallenbagel/jellyseerr/issues/110) -* **ui:** fix translation errors for all locales in the import plex user button ([0fb5803](https://github.com/fallenbagel/jellyseerr/commit/0fb5803eb9a7589141a63e13df9a8aa8ea4cebf2)) -* **ui:** fix ui elements not reflecting the env variable ([722dda5](https://github.com/fallenbagel/jellyseerr/commit/722dda585631be365a2fb400b62dbc201f2b80de)) -* **ui:** fixed translation issue where it showed as import {mediaServerName} user ([819190c](https://github.com/fallenbagel/jellyseerr/commit/819190ce98720d8d66a07c98a4f12e3c8cdcac94)) -* **ui:** rectangular avatars getting stretched ([#2782](https://github.com/fallenbagel/jellyseerr/issues/2782)) ([db05172](https://github.com/fallenbagel/jellyseerr/commit/db05172d8b924a591ece4fae72d076eb59ee5f82)) -* **ui:** replaced {mediaServerName} in the plex variable in NL locale ([d417fca](https://github.com/fallenbagel/jellyseerr/commit/d417fcafa1e38c6d56ed8360ae451e8b8ff82a8d)) - - -### Features - -* add Paramount+ to network slider ([d22bc09](https://github.com/fallenbagel/jellyseerr/commit/d22bc09652e5d4e703fca6838d06e4908432fe06)) -* **api:** add issue counts endpoint ([af23a25](https://github.com/fallenbagel/jellyseerr/commit/af23a257d5795b5c3930cd3884a84a2e2eeeb1dc)) -* **api:** add issue counts endpoint ([#2713](https://github.com/fallenbagel/jellyseerr/issues/2713)) ([e4039d0](https://github.com/fallenbagel/jellyseerr/commit/e4039d09c0380d80f03c7a00b51a150f88c02cca)) -* conditional media server name ([2bfdf02](https://github.com/fallenbagel/jellyseerr/commit/2bfdf02c7942762bd9f5201459b1a9ad6003b9a6)) -* conditional media server name to add emby to tvdetails ([e75b71b](https://github.com/fallenbagel/jellyseerr/commit/e75b71b8168b4a661971b809c88f9910c4206545)) -* conditional media server name to add emby to tvdetails ([ff3e3ce](https://github.com/fallenbagel/jellyseerr/commit/ff3e3ce841f0676713242d0c8e3a977ef65530d8)) -* **discover:** add Paramount+ to network slider ([#2608](https://github.com/fallenbagel/jellyseerr/issues/2608)) ([1d00229](https://github.com/fallenbagel/jellyseerr/commit/1d00229a485bb2b376e9f63b52c70c7719f5f023)) -* email ([a8bc0c0](https://github.com/fallenbagel/jellyseerr/commit/a8bc0c068b305710a224fa56a3725cc7e0758eb7)), closes [#122](https://github.com/fallenbagel/jellyseerr/issues/122) -* **email validation:** email requirement and validation + better importer ([d835336](https://github.com/fallenbagel/jellyseerr/commit/d835336d330abfef5b15bc9febcb748a8154c7df)) -* **manage slideover:** show more request override details ([#2772](https://github.com/fallenbagel/jellyseerr/issues/2772)) ([90095bb](https://github.com/fallenbagel/jellyseerr/commit/90095bb18548dfd663a78df1908c40dbf2f99faf)) -* **uesrprofile:** email requirement and validation ([543859e](https://github.com/fallenbagel/jellyseerr/commit/543859e6f3b3a8cd4c61499a74bda610d3217626)) -* **ui:** add emby as a mediaServerType to the import user button ([6a6bfe0](https://github.com/fallenbagel/jellyseerr/commit/6a6bfe0c6875a1d8ccb1a6fdc409f595202ef38e)) -* **ui:** add emby user badge to the user list and fix local user badge ([410b536](https://github.com/fallenbagel/jellyseerr/commit/410b536c9474806ab9f7f5f097cedfafde1fbf67)) -* **ui:** add emby user badge to the userProfile ([b9546e6](https://github.com/fallenbagel/jellyseerr/commit/b9546e6daa8583c60fac7961447a13715bbc7f6b)) -* **ui:** conditional media server name to add emby to issuedetails play on button ([377a4fd](https://github.com/fallenbagel/jellyseerr/commit/377a4fd85b7194afb48a8ba9bfa4ce4ccf996be8)) -* **ui:** conditional media server name to add emby to moviedetails ([14d2937](https://github.com/fallenbagel/jellyseerr/commit/14d293799bb37f45449c201ab03638af257623be)) -* **user email setting:** added field to save user email ([30c48f1](https://github.com/fallenbagel/jellyseerr/commit/30c48f16ca0a74e7551b533bd75bc43304f946b1)), closes [#122](https://github.com/fallenbagel/jellyseerr/issues/122) -* **user settings:** added email field to user profiel settings ([b22f20b](https://github.com/fallenbagel/jellyseerr/commit/b22f20b6fa5f68398850ccbf9b6e1cc233b3c8f4)), closes [#122](https://github.com/fallenbagel/jellyseerr/issues/122) - -# [1.1.0](https://github.com/fallenbagel/jellyseerr/compare/v1.0.2...v1.1.0) (2022-05-21) - - -### Bug Fixes - -* add Discord ID setting to general user settings page ([#2406](https://github.com/fallenbagel/jellyseerr/issues/2406)) ([eff665e](https://github.com/fallenbagel/jellyseerr/commit/eff665ef4b688aac881408790304b77bd9a31ddb)) -* add missing route guards to issues pages ([#2235](https://github.com/fallenbagel/jellyseerr/issues/2235)) ([c79dc9f](https://github.com/fallenbagel/jellyseerr/commit/c79dc9f70f512dbec0e3460ee78dbc9feccfbbb1)) -* address unhandled promise rejections & bump node to v16.13 ([#2398](https://github.com/fallenbagel/jellyseerr/issues/2398)) ([8cba486](https://github.com/fallenbagel/jellyseerr/commit/8cba486249fed88232e93a688c8bfe0f6179c589)) -* allow basic HTTP auth in hostname validation ([#2307](https://github.com/fallenbagel/jellyseerr/issues/2307)) ([d48a7ba](https://github.com/fallenbagel/jellyseerr/commit/d48a7ba518f9c79d70e499037cb730eb3efe2c08)) -* **api:** return queried user's requests instead of own requests ([#2174](https://github.com/fallenbagel/jellyseerr/issues/2174)) ([0edb1f4](https://github.com/fallenbagel/jellyseerr/commit/0edb1f452b6ff4a49ae2bde15f7273769788cf4f)) -* **api:** use query builder for user requests endpoint ([#2119](https://github.com/fallenbagel/jellyseerr/issues/2119)) ([a20f395](https://github.com/fallenbagel/jellyseerr/commit/a20f395c94c97dd7ddbc25590f15def2c9bf13c9)) -* apply request overrides iff override & selected servers match ([#2164](https://github.com/fallenbagel/jellyseerr/issues/2164)) ([50ce198](https://github.com/fallenbagel/jellyseerr/commit/50ce198471b1a3777a183d68904bbfb39ebd4523)) -* **auth:** resolve local/password authentication issues ([#2677](https://github.com/fallenbagel/jellyseerr/issues/2677)) ([b75fc7b](https://github.com/fallenbagel/jellyseerr/commit/b75fc7b2384ce760432620faaa92277dcd42b8e1)) -* **css:** rename form-input to form-input-area ([#2613](https://github.com/fallenbagel/jellyseerr/issues/2613)) ([086f0b6](https://github.com/fallenbagel/jellyseerr/commit/086f0b6ce23f607d20c2cec3c73b2e4d1ce9b426)) -* disable user-import from mediaserver for non-plex mediaservers until implemented ([4db8e54](https://github.com/fallenbagel/jellyseerr/commit/4db8e5464d6ce3450e2687a0cbee126961d847d2)) -* **docker:** explicitly install python3 ([#2273](https://github.com/fallenbagel/jellyseerr/issues/2273)) [skip ci] ([f1cd087](https://github.com/fallenbagel/jellyseerr/commit/f1cd0878a5c74bddc864f5f8ce9e2f041bdde5ec)) -* don't allow login for unimported Jellyfin users if not set in settings ([72ca694](https://github.com/fallenbagel/jellyseerr/commit/72ca694f212ab616ca7b7fe02e428ff61f79c67c)) -* **email:** do not attempt to display logo if app URL not configured ([#2125](https://github.com/fallenbagel/jellyseerr/issues/2125)) ([b3b421a](https://github.com/fallenbagel/jellyseerr/commit/b3b421a67408a4a48d23c15341fcdf7aaf19b25a)) -* **email:** enclose PGP encryption logic in try/catch ([#2519](https://github.com/fallenbagel/jellyseerr/issues/2519)) ([a76b608](https://github.com/fallenbagel/jellyseerr/commit/a76b608ab796944c0c660e3296a7aca6615d69f3)) -* **email:** use decrypted private key ([#2232](https://github.com/fallenbagel/jellyseerr/issues/2232)) ([8d29685](https://github.com/fallenbagel/jellyseerr/commit/8d2968572a569ed77a4d7c14ae1dc69935fa847e)) -* fix usertype from local user to mediaServerType ([25bee8b](https://github.com/fallenbagel/jellyseerr/commit/25bee8b9f70d7948191ba9cf07d16427da81d425)) -* **frontend:** disable autocomplete on search field ([#2592](https://github.com/fallenbagel/jellyseerr/issues/2592)) ([82d1617](https://github.com/fallenbagel/jellyseerr/commit/82d16177bf763fe8097b4aae326793e3e21e847d)) -* **frontend:** more issues-related fixes ([#2234](https://github.com/fallenbagel/jellyseerr/issues/2234)) ([3ec4a9c](https://github.com/fallenbagel/jellyseerr/commit/3ec4a9c76e1f31bee5c8801b389721bf8e5884e0)) -* **frontend:** notification type validation ([#2207](https://github.com/fallenbagel/jellyseerr/issues/2207)) ([2f204b9](https://github.com/fallenbagel/jellyseerr/commit/2f204b995269a53ae36f2a8733f27ae6ab70da5a)) -* **frontend:** setup page backdrops ([#2251](https://github.com/fallenbagel/jellyseerr/issues/2251)) ([78a8091](https://github.com/fallenbagel/jellyseerr/commit/78a8091bcd29a7cf50cc7c493c28710389817adf)) -* **frontend:** theme-color meta tag ([#2420](https://github.com/fallenbagel/jellyseerr/issues/2420)) ([ff28c9b](https://github.com/fallenbagel/jellyseerr/commit/ff28c9bfebf4a930e2542ee3b3c35f8af4e1b97e)) -* **frontend:** use consistent formatting & strings ([#2231](https://github.com/fallenbagel/jellyseerr/issues/2231)) ([2164471](https://github.com/fallenbagel/jellyseerr/commit/216447121b686b6d01a31b95ec0c8eb005f6b103)) -* **frontend:** various fixes ([#2524](https://github.com/fallenbagel/jellyseerr/issues/2524)) ([c3dbd0d](https://github.com/fallenbagel/jellyseerr/commit/c3dbd0d6913946e0e1b5308edfbb5ca744740223)) -* handle Plex library settings migration failure gracefully ([#2254](https://github.com/fallenbagel/jellyseerr/issues/2254)) ([ed53810](https://github.com/fallenbagel/jellyseerr/commit/ed53810fb33f70722361c67d176ff4edf531ba45)) -* **holiday:** remove special holiday slider ([22f2037](https://github.com/fallenbagel/jellyseerr/commit/22f2037ea6c5a0ba2ffa4d69f2b7cf42bdcf8575)) -* **issues:** only allow edit of own comments & do not allow non-admin delete of issues with comments ([#2248](https://github.com/fallenbagel/jellyseerr/issues/2248)) ([bba09d6](https://github.com/fallenbagel/jellyseerr/commit/bba09d69c1bc55c2f35db5a7986e7c935cc9619c)) -* jellyfin user signin after manual user import ([36c3c9d](https://github.com/fallenbagel/jellyseerr/commit/36c3c9d7c60176a5c4090b86313743b3ce433406)) -* **lang:** add missing string ([#2370](https://github.com/fallenbagel/jellyseerr/issues/2370)) ([d36c1d2](https://github.com/fallenbagel/jellyseerr/commit/d36c1d29295020efb76bac21a443b6f9049802f3)) -* **lang:** rename 'Media' notification types for clarity ([#2400](https://github.com/fallenbagel/jellyseerr/issues/2400)) ([399b037](https://github.com/fallenbagel/jellyseerr/commit/399b0379186ed34dcc436bd95330fd1a05fef4b3)) -* **lang:** string edits ([#2229](https://github.com/fallenbagel/jellyseerr/issues/2229)) ([ab20c21](https://github.com/fallenbagel/jellyseerr/commit/ab20c21184639e1c7725f7cae96249c6fa157351)) -* **lang:** translations update from Hosted Weblate ([#2625](https://github.com/fallenbagel/jellyseerr/issues/2625)) ([19cdedd](https://github.com/fallenbagel/jellyseerr/commit/19cdedd2a6656b1a852e1cc653bbdb140e978b51)) -* **lang:** translations update from Hosted Weblate ([#2639](https://github.com/fallenbagel/jellyseerr/issues/2639)) ([418a533](https://github.com/fallenbagel/jellyseerr/commit/418a533588bbbdbbbb4caee1ef91d57c1ca35717)) -* **lang:** translations update from Weblate ([#2212](https://github.com/fallenbagel/jellyseerr/issues/2212)) ([85aec4f](https://github.com/fallenbagel/jellyseerr/commit/85aec4f8925746ebae9bcc99d8480b78ccfd851e)) -* **logs:** handle log message nested extra properties ([#2459](https://github.com/fallenbagel/jellyseerr/issues/2459)) ([d777940](https://github.com/fallenbagel/jellyseerr/commit/d7779408d162949b2eafcacefc8eabe53fae229f)) -* **logs:** handle unexpected log messages ([#2303](https://github.com/fallenbagel/jellyseerr/issues/2303)) ([f284e4a](https://github.com/fallenbagel/jellyseerr/commit/f284e4ab978e502d2cc08e76226a8ebac91bb48f)) -* **logs:** lazily parse log message label ([#2359](https://github.com/fallenbagel/jellyseerr/issues/2359)) ([5af06bd](https://github.com/fallenbagel/jellyseerr/commit/5af06bd87226fbc6176b0c5e362824793165a34e)) -* **notif:** correct issue notif action URLs ([#2333](https://github.com/fallenbagel/jellyseerr/issues/2333)) ([dc7f959](https://github.com/fallenbagel/jellyseerr/commit/dc7f959cb422a8d89bcebc78377f1513412e542c)) -* **notif:** duplicate notification check logic ([#2424](https://github.com/fallenbagel/jellyseerr/issues/2424)) ([10651ba](https://github.com/fallenbagel/jellyseerr/commit/10651baa675993f7109989bbac67f54661c8693f)) -* **notif:** only send MEDIA_AVAILABLE notifications for non-declined requests ([#2343](https://github.com/fallenbagel/jellyseerr/issues/2343)) ([fcb0dcf](https://github.com/fallenbagel/jellyseerr/commit/fcb0dcf5be64bf9ca814bfe119586908922099c5)) -* **notif:** show event in pop up notification for slack ([#2413](https://github.com/fallenbagel/jellyseerr/issues/2413)) ([d4438c8](https://github.com/fallenbagel/jellyseerr/commit/d4438c82e3753c9b29b6269ad406d263b3fcef4c)), closes [#2408](https://github.com/fallenbagel/jellyseerr/issues/2408) -* only run scheduled mediaserver jobs that apply to the current mediaserver ([791106a](https://github.com/fallenbagel/jellyseerr/commit/791106a7f5b8356b67119300bad245f587f6dc5f)) -* play on Jellyfin for TV shows ([d0c5481](https://github.com/fallenbagel/jellyseerr/commit/d0c5481d22ddceee0b5c3d7d82029f44c46dbbd0)) -* plex Login ([9d54776](https://github.com/fallenbagel/jellyseerr/commit/9d54776a2c4c23a61d5e619ca952b9e5d947a79b)) -* **plex:** correctly generate uuid for safari ([#2614](https://github.com/fallenbagel/jellyseerr/issues/2614)) ([d06f2cd](https://github.com/fallenbagel/jellyseerr/commit/d06f2cdb08bfa6f05cf7cec2c408a258fa926b09)) -* **plex:** find TV series in addition to movies from IMDb IDs ([#1830](https://github.com/fallenbagel/jellyseerr/issues/1830)) ([30644f6](https://github.com/fallenbagel/jellyseerr/commit/30644f65ea2e8437676422ae0b083c642a836887)) -* **plex:** include 'Overseerr' in X-Plex-Device-Name header ([#2635](https://github.com/fallenbagel/jellyseerr/issues/2635)) ([d4f9650](https://github.com/fallenbagel/jellyseerr/commit/d4f9650cd07704a97f8b591b7de7351c1e85b825)) -* **plex:** use unique client identifier ([#2602](https://github.com/fallenbagel/jellyseerr/issues/2602)) ([648b346](https://github.com/fallenbagel/jellyseerr/commit/648b346cbe5a941c7e1ec4ddfb276fb0e27ed502)) -* **plex:** user import ([#2442](https://github.com/fallenbagel/jellyseerr/issues/2442)) ([86dff12](https://github.com/fallenbagel/jellyseerr/commit/86dff12cdeef6dca92527dd31757a3a4c7f921bf)) -* **radarr:** correctly check for existing movies ([#2490](https://github.com/fallenbagel/jellyseerr/issues/2490)) ([5d4b06b](https://github.com/fallenbagel/jellyseerr/commit/5d4b06bbcc6cf6d328f6b4a86c4c0f9b0f3aff3e)) -* **radarr:** remove PreDB minimum availability option ([#2386](https://github.com/fallenbagel/jellyseerr/issues/2386)) ([3e5eb4e](https://github.com/fallenbagel/jellyseerr/commit/3e5eb4e148a9f88b871abc4ee1784b870f691534)) -* relax jellyfin url validation to allow local domains ([3a010f8](https://github.com/fallenbagel/jellyseerr/commit/3a010f821189414efd334b4cad2a300501f40a18)) -* replaced unknown job with jellyfin in jobsandcache and added translations for it ([f09b86a](https://github.com/fallenbagel/jellyseerr/commit/f09b86aa87d84af1ddee07390a04dd8543cff8a6)) -* **requests:** check for existing media of same type when requesting ([#2445](https://github.com/fallenbagel/jellyseerr/issues/2445)) ([eb9ca2e](https://github.com/fallenbagel/jellyseerr/commit/eb9ca2e86f3be3f4ff8ee2e7c4aecdf337d8976d)) -* **requests:** do not fail request edits if acting user lacks Manage Users permission ([#2338](https://github.com/fallenbagel/jellyseerr/issues/2338)) ([91bfff7](https://github.com/fallenbagel/jellyseerr/commit/91bfff71b7c05c9b9aad2c95282533eefbb6b2e7)) -* **scripts:** update migration scripts ([#2208](https://github.com/fallenbagel/jellyseerr/issues/2208)) [skip ci] ([d0ac74e](https://github.com/fallenbagel/jellyseerr/commit/d0ac74ea4bbfcf3d25d30cbd422d9df1c1259a18)) -* secure session cookie ([#2308](https://github.com/fallenbagel/jellyseerr/issues/2308)) ([7f330af](https://github.com/fallenbagel/jellyseerr/commit/7f330aff2e1d3546e8dd1a3e4b037b9beb1cc7f0)) -* **servarr:** handle baseurl error when testing connection ([#2294](https://github.com/fallenbagel/jellyseerr/issues/2294)) ([93b5ea2](https://github.com/fallenbagel/jellyseerr/commit/93b5ea20ca590996f6dc90713a76800180d0621c)) -* **servarr:** handle servaarr server being unavailable when scanning downloads ([#2358](https://github.com/fallenbagel/jellyseerr/issues/2358)) ([488874f](https://github.com/fallenbagel/jellyseerr/commit/488874fc17e4e4719e90d383b83b1e1a5217213b)) -* **sonarr:** monitor existing series upon request approval ([#2553](https://github.com/fallenbagel/jellyseerr/issues/2553)) ([aa062d9](https://github.com/fallenbagel/jellyseerr/commit/aa062d921c425d4b64bfdb28a5f102b0c92f7d87)) -* **sonarr:** only scan seasons that exist in TMDb ([#2523](https://github.com/fallenbagel/jellyseerr/issues/2523)) ([6168185](https://github.com/fallenbagel/jellyseerr/commit/61681857b123802aaeff02a8f61b1ba046c5d333)) -* sort collection parts by release date ([#2368](https://github.com/fallenbagel/jellyseerr/issues/2368)) ([1b3797c](https://github.com/fallenbagel/jellyseerr/commit/1b3797cf6e6ef6b3d8c81e644382f6e3f68cfaaa)) -* **tautulli:** fetch additional user history as necessary to return 20 unique media ([#2446](https://github.com/fallenbagel/jellyseerr/issues/2446)) ([7d19de6](https://github.com/fallenbagel/jellyseerr/commit/7d19de6a4af6297be18140ca59402b40f7bbb30b)) -* **ui:** Fix webhook URL validation regex ([#864](https://github.com/fallenbagel/jellyseerr/issues/864)) ([726f62b](https://github.com/fallenbagel/jellyseerr/commit/726f62b9b69b5078e718f129e26abdf358f5cb06)) -* **ui:** refinements for 'About' page ([#2173](https://github.com/fallenbagel/jellyseerr/issues/2173)) ([084a842](https://github.com/fallenbagel/jellyseerr/commit/084a842a4f9b6caaed22edbe77bc9e414bc1f387)) -* **ui:** request badge styling in request list ([#2302](https://github.com/fallenbagel/jellyseerr/issues/2302)) ([f2375c9](https://github.com/fallenbagel/jellyseerr/commit/f2375c902b79dcb1f349500862775ae57ea7d406)) - - -### Features - -* **about:** show config directory ([#2600](https://github.com/fallenbagel/jellyseerr/issues/2600)) ([0c7373c](https://github.com/fallenbagel/jellyseerr/commit/0c7373c7e89a4ff717efaa7d6a5854f7ccd6a8d3)) -* add emby detail url support ([88c2c5e](https://github.com/fallenbagel/jellyseerr/commit/88c2c5ebcddd1eb8aea4a4e72c68a91197dec065)) -* add production countries to movie/TV detail pages ([#2170](https://github.com/fallenbagel/jellyseerr/issues/2170)) ([30b20df](https://github.com/fallenbagel/jellyseerr/commit/30b20df37a9604ba1c066f89e54a5482a09575ea)) -* add quotas, advanced options, and toggles to collection request modal ([#1742](https://github.com/fallenbagel/jellyseerr/issues/1742)) ([af40212](https://github.com/fallenbagel/jellyseerr/commit/af40212a738f8d6d9a5bf26dc20c0c87780d6020)) -* allow Jellyfin to set a playback URL different to the Jellyfin host specified during setup ([9fbc407](https://github.com/fallenbagel/jellyseerr/commit/9fbc4074e491bbeba7880fd54c99d4e3c95c7d01)) -* **api:** add additional request counts ([#2426](https://github.com/fallenbagel/jellyseerr/issues/2426)) ([2535edc](https://github.com/fallenbagel/jellyseerr/commit/2535edcc7fd6ec66fd45ad754c03929f1fe94871)) -* **discord:** add 'Enable Mentions' setting ([#1779](https://github.com/fallenbagel/jellyseerr/issues/1779)) ([5f7538a](https://github.com/fallenbagel/jellyseerr/commit/5f7538ae2bf9c6e2feea385cc299bd08df071218)) -* display release dates for theatrical, digital, and physical release types ([#1492](https://github.com/fallenbagel/jellyseerr/issues/1492)) ([a4dca23](https://github.com/fallenbagel/jellyseerr/commit/a4dca2356b7605026f7bc45b691496e765c3328c)) -* dynamically fetch login screen backdrop images ([#2206](https://github.com/fallenbagel/jellyseerr/issues/2206)) ([3486d0b](https://github.com/fallenbagel/jellyseerr/commit/3486d0bf5520cbdff60bd8fd023caed76c452973)) -* **frontend:** add Discovery+ to network slider ([#2345](https://github.com/fallenbagel/jellyseerr/issues/2345)) ([2ded8f5](https://github.com/fallenbagel/jellyseerr/commit/2ded8f5484168bd7b8f45124d9ebdd296a5708d5)) -* **frontend:** add Hulu to network slider ([#2204](https://github.com/fallenbagel/jellyseerr/issues/2204)) ([1e402f7](https://github.com/fallenbagel/jellyseerr/commit/1e402f710b53c11855aab0abdb4b12c51c30b022)) -* **frontend:** open media management slideover on status badge click ([#2407](https://github.com/fallenbagel/jellyseerr/issues/2407)) ([1f5785d](https://github.com/fallenbagel/jellyseerr/commit/1f5785d6c53b2ca2da67a8ccee72165c052c61a1)) -* implement import users from Jellyfin button ([9e2f3f0](https://github.com/fallenbagel/jellyseerr/commit/9e2f3f06393e71ba5d1c0ba3c9512b64a3ce3ad7)) -* initialize Jellyfin/Emby users with local login ([103350f](https://github.com/fallenbagel/jellyseerr/commit/103350fe146fbf212b12a3348bcfb40399e1a0fc)) -* issues ([#2180](https://github.com/fallenbagel/jellyseerr/issues/2180)) ([e402c42](https://github.com/fallenbagel/jellyseerr/commit/e402c42aaa7d795cd724856a2e23615bb1a3695d)) -* **jobs:** allow modifying job schedules ([#1440](https://github.com/fallenbagel/jellyseerr/issues/1440)) ([82614ca](https://github.com/fallenbagel/jellyseerr/commit/82614ca4410782a12d65b4c0a6526ff064be1241)) -* **lang:** add Albanian display language ([#2605](https://github.com/fallenbagel/jellyseerr/issues/2605)) ([3d32462](https://github.com/fallenbagel/jellyseerr/commit/3d32462f50b4ced0d9205b79003c35d6d1c948a3)) -* **lang:** add Czech and Danish display languages ([#2176](https://github.com/fallenbagel/jellyseerr/issues/2176)) ([8d8db6c](https://github.com/fallenbagel/jellyseerr/commit/8d8db6cf5d98d4e498a31db339d02f8a98057c8d)) -* **lang:** add Polish display language ([#2261](https://github.com/fallenbagel/jellyseerr/issues/2261)) ([c760cea](https://github.com/fallenbagel/jellyseerr/commit/c760ceaa5f36c77fa3ce320fae1b4597d2d8b976)) -* **lang:** translated using Weblate (Chinese (Traditional)) ([#2272](https://github.com/fallenbagel/jellyseerr/issues/2272)) ([d401e33](https://github.com/fallenbagel/jellyseerr/commit/d401e33249cbbca6e707479e5f0207e298ef3248)) -* **lang:** translations update from Hosted Weblate ([#2277](https://github.com/fallenbagel/jellyseerr/issues/2277)) ([92732fc](https://github.com/fallenbagel/jellyseerr/commit/92732fcb42c56242d16daab00e2d38740b92dea0)) -* **lang:** translations update from Hosted Weblate ([#2315](https://github.com/fallenbagel/jellyseerr/issues/2315)) ([6245be1](https://github.com/fallenbagel/jellyseerr/commit/6245be1e10dda67c869b59522c1290e7c100145f)) -* **lang:** translations update from Hosted Weblate ([#2320](https://github.com/fallenbagel/jellyseerr/issues/2320)) ([68112fa](https://github.com/fallenbagel/jellyseerr/commit/68112faefbd64d5c71d3eff21620767f88ccfc34)) -* **lang:** translations update from Hosted Weblate ([#2325](https://github.com/fallenbagel/jellyseerr/issues/2325)) ([febf067](https://github.com/fallenbagel/jellyseerr/commit/febf0677b880d2fed2822ce510db7cbb0826a920)) -* **lang:** translations update from Hosted Weblate ([#2336](https://github.com/fallenbagel/jellyseerr/issues/2336)) ([3f7ef7a](https://github.com/fallenbagel/jellyseerr/commit/3f7ef7af97a807ef38041f4f2642b565aa33d066)) -* **lang:** translations update from Hosted Weblate ([#2341](https://github.com/fallenbagel/jellyseerr/issues/2341)) ([33fe0bd](https://github.com/fallenbagel/jellyseerr/commit/33fe0bdd1e00da40e85b4e4b4780134b31a105d2)) -* **lang:** translations update from Hosted Weblate ([#2346](https://github.com/fallenbagel/jellyseerr/issues/2346)) ([50dc934](https://github.com/fallenbagel/jellyseerr/commit/50dc9341dd98cb2d8ef3ef6471882a5a9b060afa)) -* **lang:** translations update from Hosted Weblate ([#2364](https://github.com/fallenbagel/jellyseerr/issues/2364)) ([d437cc2](https://github.com/fallenbagel/jellyseerr/commit/d437cc25392e9c0881888371ffabc82892a1b15c)) -* **lang:** translations update from Hosted Weblate ([#2366](https://github.com/fallenbagel/jellyseerr/issues/2366)) ([cc2b2bc](https://github.com/fallenbagel/jellyseerr/commit/cc2b2bc7a8ecd89e1feb38a907596b16df9bf0fc)) -* **lang:** translations update from Hosted Weblate ([#2374](https://github.com/fallenbagel/jellyseerr/issues/2374)) ([b9bedac](https://github.com/fallenbagel/jellyseerr/commit/b9bedac7d7ba85223ecf1d9b93b96e2a490d571a)) -* **lang:** translations update from Hosted Weblate ([#2379](https://github.com/fallenbagel/jellyseerr/issues/2379)) ([bd93168](https://github.com/fallenbagel/jellyseerr/commit/bd93168ba1ed650baf4024569bb6a76811a99820)) -* **lang:** translations update from Hosted Weblate ([#2389](https://github.com/fallenbagel/jellyseerr/issues/2389)) ([d2241a4](https://github.com/fallenbagel/jellyseerr/commit/d2241a41877d126a802fc53c925d258af31f34fd)) -* **lang:** translations update from Hosted Weblate ([#2404](https://github.com/fallenbagel/jellyseerr/issues/2404)) ([1b29b15](https://github.com/fallenbagel/jellyseerr/commit/1b29b15d7c9a7ec918cb59116d60e1ae2e797dc4)) -* **lang:** translations update from Hosted Weblate ([#2405](https://github.com/fallenbagel/jellyseerr/issues/2405)) ([879df20](https://github.com/fallenbagel/jellyseerr/commit/879df20022c8c5d9b32858ac5499d3e4369fc064)) -* **lang:** translations update from Hosted Weblate ([#2414](https://github.com/fallenbagel/jellyseerr/issues/2414)) ([88536b1](https://github.com/fallenbagel/jellyseerr/commit/88536b1f9d6e8c1a11e1adf91b85bab4f34b751c)) -* **lang:** translations update from Hosted Weblate ([#2425](https://github.com/fallenbagel/jellyseerr/issues/2425)) ([e9d4b63](https://github.com/fallenbagel/jellyseerr/commit/e9d4b6327b50a005ee6c2c3292b6f107e90fc50c)) -* **lang:** translations update from Hosted Weblate ([#2428](https://github.com/fallenbagel/jellyseerr/issues/2428)) ([f8b1bcc](https://github.com/fallenbagel/jellyseerr/commit/f8b1bccda44371bb6f3f8f4ceeab900b1df3de31)) -* **lang:** translations update from Hosted Weblate ([#2436](https://github.com/fallenbagel/jellyseerr/issues/2436)) ([99c0407](https://github.com/fallenbagel/jellyseerr/commit/99c04072e9f7be8191f25cbcfd5103017b8796eb)) -* **lang:** translations update from Hosted Weblate ([#2452](https://github.com/fallenbagel/jellyseerr/issues/2452)) ([b5bd6ee](https://github.com/fallenbagel/jellyseerr/commit/b5bd6ee78f3d4aa14f0c440d1f2a8323dccfa399)) -* **lang:** translations update from Hosted Weblate ([#2457](https://github.com/fallenbagel/jellyseerr/issues/2457)) ([92b2d32](https://github.com/fallenbagel/jellyseerr/commit/92b2d32d2e1e1d319410a9e357e1304065a77598)) -* **lang:** translations update from Hosted Weblate ([#2489](https://github.com/fallenbagel/jellyseerr/issues/2489)) ([ec08fa6](https://github.com/fallenbagel/jellyseerr/commit/ec08fa67934715ff4a4d618d5b9ff97853913b78)) -* **lang:** translations update from Hosted Weblate ([#2508](https://github.com/fallenbagel/jellyseerr/issues/2508)) ([9f4ae34](https://github.com/fallenbagel/jellyseerr/commit/9f4ae34da76707a40e2c89a50c722ffa1c0327c0)) -* **lang:** translations update from Hosted Weblate ([#2531](https://github.com/fallenbagel/jellyseerr/issues/2531)) ([54b32eb](https://github.com/fallenbagel/jellyseerr/commit/54b32ebfd6b2eb6aeeea98c25939166eda8cc17f)) -* **lang:** translations update from Hosted Weblate ([#2541](https://github.com/fallenbagel/jellyseerr/issues/2541)) ([4549ed3](https://github.com/fallenbagel/jellyseerr/commit/4549ed389e4f25c0946dc01526387e5ac000c3cf)) -* **lang:** translations update from Hosted Weblate ([#2611](https://github.com/fallenbagel/jellyseerr/issues/2611)) ([81c75c8](https://github.com/fallenbagel/jellyseerr/commit/81c75c800edf6d36a1082a291ef7e308f338d005)) -* **lang:** translations update from Hosted Weblate ([#2629](https://github.com/fallenbagel/jellyseerr/issues/2629)) ([1d0cbd2](https://github.com/fallenbagel/jellyseerr/commit/1d0cbd2e761072be0b4b3de461397ad9f9f681f3)) -* **lang:** translations update from Hosted Weblate ([#2645](https://github.com/fallenbagel/jellyseerr/issues/2645)) ([341e3b8](https://github.com/fallenbagel/jellyseerr/commit/341e3b8f0657e09f53ad0b813b051290947343c0)) -* **lang:** translations update from Weblate ([#2101](https://github.com/fallenbagel/jellyseerr/issues/2101)) ([c73cf7b](https://github.com/fallenbagel/jellyseerr/commit/c73cf7b19cbc19e97a777c0facb9264fb0113093)) -* **lang:** translations update from Weblate ([#2179](https://github.com/fallenbagel/jellyseerr/issues/2179)) ([e3312ce](https://github.com/fallenbagel/jellyseerr/commit/e3312cef33821c8cb76a4a63bd565c78d67b3e0b)) -* **lang:** translations update from Weblate ([#2185](https://github.com/fallenbagel/jellyseerr/issues/2185)) ([dce10f7](https://github.com/fallenbagel/jellyseerr/commit/dce10f743f52cb04036e2cdaee280e26a81b253b)) -* **lang:** translations update from Weblate ([#2202](https://github.com/fallenbagel/jellyseerr/issues/2202)) ([492d8e3](https://github.com/fallenbagel/jellyseerr/commit/492d8e3daa5fb99aa9df2a18978085d5ddd581e7)) -* **lang:** translations update from Weblate ([#2210](https://github.com/fallenbagel/jellyseerr/issues/2210)) ([0a6ef6c](https://github.com/fallenbagel/jellyseerr/commit/0a6ef6cc81376f7a02f1483109be7ae4ab851c48)) -* **lang:** translations update from Weblate ([#2226](https://github.com/fallenbagel/jellyseerr/issues/2226)) ([62b3dc5](https://github.com/fallenbagel/jellyseerr/commit/62b3dc5471c28f4d0e4399cb3bc8bfab94cff5ea)) -* **lang:** translations update from Weblate ([#2241](https://github.com/fallenbagel/jellyseerr/issues/2241)) ([2b0b8e0](https://github.com/fallenbagel/jellyseerr/commit/2b0b8e05d9c95ff9218cea858a920a2815871186)) -* **lang:** translations update from Weblate ([#2244](https://github.com/fallenbagel/jellyseerr/issues/2244)) ([0828b00](https://github.com/fallenbagel/jellyseerr/commit/0828b008badc8b512316799a6787bb7c403658d5)) -* **lang:** translations update from Weblate ([#2247](https://github.com/fallenbagel/jellyseerr/issues/2247)) ([8c49309](https://github.com/fallenbagel/jellyseerr/commit/8c49309c35c31f7bcd0b84b0a307febc16842f68)) -* **lang:** translations update from Weblate ([#2252](https://github.com/fallenbagel/jellyseerr/issues/2252)) ([99d5000](https://github.com/fallenbagel/jellyseerr/commit/99d50004e58f6b4594df0a171f6bc668635ec50c)) -* **lang:** translations update from Weblate ([#2265](https://github.com/fallenbagel/jellyseerr/issues/2265)) ([b1b367a](https://github.com/fallenbagel/jellyseerr/commit/b1b367aac625ed3eb865832c94c2352e5a5c40f5)) -* **logs:** use separate json file to parse logs for log viewer ([#2399](https://github.com/fallenbagel/jellyseerr/issues/2399)) ([ce31bef](https://github.com/fallenbagel/jellyseerr/commit/ce31bef8a125c5492f2a1cfef0dcf3d8a4e9ee11)) -* **notif:** 4K media notifications ([#2324](https://github.com/fallenbagel/jellyseerr/issues/2324)) ([88a8c1a](https://github.com/fallenbagel/jellyseerr/commit/88a8c1aa596e1113d6da52e5e8cbe443abc6384f)) -* **notif:** add Gotify agent ([#2196](https://github.com/fallenbagel/jellyseerr/issues/2196)) ([e0b6abe](https://github.com/fallenbagel/jellyseerr/commit/e0b6abe4796f5a324c0ff78cff317fcaead671f1)), closes [#2183](https://github.com/fallenbagel/jellyseerr/issues/2183) [#2183](https://github.com/fallenbagel/jellyseerr/issues/2183) [#2077](https://github.com/fallenbagel/jellyseerr/issues/2077) [#2183](https://github.com/fallenbagel/jellyseerr/issues/2183) [#2183](https://github.com/fallenbagel/jellyseerr/issues/2183) [#2183](https://github.com/fallenbagel/jellyseerr/issues/2183) [#2077](https://github.com/fallenbagel/jellyseerr/issues/2077) [#2183](https://github.com/fallenbagel/jellyseerr/issues/2183) [#2183](https://github.com/fallenbagel/jellyseerr/issues/2183) [#2183](https://github.com/fallenbagel/jellyseerr/issues/2183) -* **notif:** add Pushbullet and Pushover agents to user notification settings ([#1740](https://github.com/fallenbagel/jellyseerr/issues/1740)) ([aeb7a48](https://github.com/fallenbagel/jellyseerr/commit/aeb7a48d72cec3fa2b857030aad3eaa0a457a896)) -* **notif:** add Pushbullet channel tag ([#2198](https://github.com/fallenbagel/jellyseerr/issues/2198)) ([f9200b7](https://github.com/fallenbagel/jellyseerr/commit/f9200b7977208f9b8267ce3a74bd8a86d6f28f7b)) -* **notif:** issue notifications ([#2242](https://github.com/fallenbagel/jellyseerr/issues/2242)) ([c9ffac3](https://github.com/fallenbagel/jellyseerr/commit/c9ffac33f7c04d926f8c45295703689d42fe87af)) -* **plex:** selective user import ([#2188](https://github.com/fallenbagel/jellyseerr/issues/2188)) ([9cb97db](https://github.com/fallenbagel/jellyseerr/commit/9cb97db13ced5df2dc595cd9033470b1a0750093)) -* remove email requirement for jellyfin/emby non-admin users ([3e1e11d](https://github.com/fallenbagel/jellyseerr/commit/3e1e11d9d93e5d055c92989361a3ced3b77b1d39)) -* **search:** close search bar when hitting return ([#2260](https://github.com/fallenbagel/jellyseerr/issues/2260)) ([b423dc1](https://github.com/fallenbagel/jellyseerr/commit/b423dc167d12f0ba49f902876bceb2e876e35f58)) -* **search:** filter search results by year ([#2460](https://github.com/fallenbagel/jellyseerr/issues/2460)) ([72c825d](https://github.com/fallenbagel/jellyseerr/commit/72c825d2a5109688bcc1991a30249284bf281500)) -* **search:** search by id ([#2082](https://github.com/fallenbagel/jellyseerr/issues/2082)) ([b31cdbf](https://github.com/fallenbagel/jellyseerr/commit/b31cdbf074d5dbecbbf6da135a9b686aea9e3c0e)) -* **servarr:** auto fill base url when testing service if missing ([#1995](https://github.com/fallenbagel/jellyseerr/issues/1995)) ([739f667](https://github.com/fallenbagel/jellyseerr/commit/739f667b54d8dec258b74d0cd8fd8b3b88dcf8d5)) -* Tautulli integration ([#2230](https://github.com/fallenbagel/jellyseerr/issues/2230)) ([0842c23](https://github.com/fallenbagel/jellyseerr/commit/0842c233d0fc56d44824cad18749492cd52cbed5)) -* **tautulli:** validate upon saving settings ([#2511](https://github.com/fallenbagel/jellyseerr/issues/2511)) ([1dc900d](https://github.com/fallenbagel/jellyseerr/commit/1dc900d5ce9689d179c9d2f554abc74ca50bd9cb)) -* **ui:** add trakt external link ([#2367](https://github.com/fallenbagel/jellyseerr/issues/2367)) ([4e56bae](https://github.com/fallenbagel/jellyseerr/commit/4e56bae98508c1a60aeb3a08560ba1c00acce7e7)) -* **ui:** allow admins to edit & approve request from advanced request modal ([#2067](https://github.com/fallenbagel/jellyseerr/issues/2067)) ([340f1a2](https://github.com/fallenbagel/jellyseerr/commit/340f1a211952bd2e8f40f0ea4622b52dbe934e85)) -* **ui:** link processing/requested status badges to service URL ([#1761](https://github.com/fallenbagel/jellyseerr/issues/1761)) ([032c14a](https://github.com/fallenbagel/jellyseerr/commit/032c14a22680f62f8106943297b081b68645ce61)) -* verify Plex server access during auth for existing users with Plex IDs ([#2458](https://github.com/fallenbagel/jellyseerr/issues/2458)) ([85bb30e](https://github.com/fallenbagel/jellyseerr/commit/85bb30e252c27047ae367491f0e5bb92a7d52605)) - -## [1.29.1](https://github.com/sct/overseerr/compare/v1.29.0...v1.29.1) (2022-04-06) - ### Bug Fixes - **auth:** resolve local/password authentication issues ([#2677](https://github.com/sct/overseerr/issues/2677)) ([b75fc7b](https://github.com/sct/overseerr/commit/b75fc7b2384ce760432620faaa92277dcd42b8e1)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a016f6d46..96e67c8ac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,7 +86,7 @@ When adding new UI text, please try to adhere to the following guidelines: 1. Be concise and clear, and use as few words as possible to make your point. 2. Use the Oxford comma where appropriate. 3. Use the appropriate Unicode characters for ellipses, arrows, and other special characters/symbols. -4. Capitalize proper nouns, such as Plex, Radarr, Sonarr, Telegram, Slack, Pushover, etc. Be sure to also use the official capitalization for any abbreviations; e.g., TMDb and IMDb have a lowercase 'b', whereas TheTVDB has a capital 'B'. +4. Capitalize proper nouns, such as Plex, Radarr, Sonarr, Telegram, Slack, Pushover, etc. Be sure to also use the official capitalization for any abbreviations; e.g., IMDb has a lowercase 'b', whereas TMDB and TheTVDB have a capital 'B'. 5. Title case headings, button text, and form labels. Note that verbs such as "is" should be capitalized, whereas prepositions like "from" should be lowercase (unless as the first or last word of the string, in which case they are also capitalized). 6. Capitalize the first word in validation error messages, dropdowns, and form "tips." These strings should not end in punctuation. 7. Ensure that toast notification strings are complete sentences ending in punctuation. diff --git a/Dockerfile b/Dockerfile index 8f3ed32c8..851ba4721 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:16.14-alpine AS BUILD_IMAGE +FROM node:16.17-alpine AS BUILD_IMAGE WORKDIR /app @@ -14,7 +14,7 @@ RUN \ esac COPY package.json yarn.lock ./ -RUN yarn install --frozen-lockfile --network-timeout 1000000 +RUN CYPRESS_INSTALL_BINARY=0 yarn install --frozen-lockfile --network-timeout 1000000 COPY . ./ @@ -33,7 +33,7 @@ RUN touch config/DOCKER RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json -FROM node:16.14-alpine +FROM node:16.17-alpine WORKDIR /app diff --git a/Dockerfile.local b/Dockerfile.local index f0228b6b9..39e0534f3 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -1,4 +1,4 @@ -FROM node:16.14-alpine +FROM node:16.17-alpine COPY . /app WORKDIR /app diff --git a/README.md b/README.md index c195050ab..614dcadb5 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,15 @@ **Jellyseerr** is a free and open source software application for managing requests for your media library. It is a a fork of Overseerr built to bring support for Jellyfin & Emby media servers! +_The original Overseerr team have been busy and Jellyfin/Emby support aren't on their roadmap, so we started this project as we wanted to bring the Overseerr experience to the Jellyfin/Emby Community!_ + ## Current Features - Jellyfin Support - Emby Support + (Upcoming Features include: Multiple Server Instances, Music Support, Ability to change email address and much more!) + Along with all the existing Overseerr features: - Full Plex integration. Authenticate and manage user access with Plex! diff --git a/cypress.config.ts b/cypress.config.ts new file mode 100644 index 000000000..07b0c8b1d --- /dev/null +++ b/cypress.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'cypress'; + +export default defineConfig({ + projectId: 'onnqy3', + e2e: { + baseUrl: 'http://localhost:5055', + experimentalSessionAndOrigin: true, + }, + env: { + ADMIN_EMAIL: 'admin@seerr.dev', + ADMIN_PASSWORD: 'test1234', + USER_EMAIL: 'friend@seerr.dev', + USER_PASSWORD: 'test1234', + }, + retries: { + runMode: 2, + openMode: 0, + }, +}); diff --git a/cypress/config/settings.cypress.json b/cypress/config/settings.cypress.json new file mode 100644 index 000000000..bb7b661b0 --- /dev/null +++ b/cypress/config/settings.cypress.json @@ -0,0 +1,149 @@ +{ + "clientId": "6919275e-142a-48d8-be6b-93594cbd4626", + "vapidPrivate": "tmnslaO8ZWN6bNbSEv_rolPeBTlNxOwCCAHrM9oZz3M", + "vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs", + "main": { + "apiKey": "testkey", + "applicationTitle": "Overseerr", + "applicationUrl": "", + "csrfProtection": false, + "cacheImages": false, + "defaultPermissions": 32, + "defaultQuotas": { + "movie": {}, + "tv": {} + }, + "hideAvailable": false, + "localLogin": true, + "newPlexLogin": true, + "region": "", + "originalLanguage": "", + "trustProxy": false, + "partialRequestsEnabled": true, + "locale": "en" + }, + "plex": { + "name": "Seerr", + "ip": "192.168.1.1", + "port": 32400, + "useSsl": false, + "libraries": [ + { + "id": "1", + "name": "Movies", + "enabled": true, + "type": "movie" + } + ], + "machineId": "test" + }, + "tautulli": {}, + "radarr": [], + "sonarr": [], + "public": { + "initialized": true + }, + "notifications": { + "agents": { + "email": { + "enabled": false, + "options": { + "emailFrom": "", + "smtpHost": "", + "smtpPort": 587, + "secure": false, + "ignoreTls": false, + "requireTls": false, + "allowSelfSigned": false, + "senderName": "Overseerr" + } + }, + "discord": { + "enabled": false, + "types": 0, + "options": { + "webhookUrl": "", + "enableMentions": true + } + }, + "lunasea": { + "enabled": false, + "types": 0, + "options": { + "webhookUrl": "" + } + }, + "slack": { + "enabled": false, + "types": 0, + "options": { + "webhookUrl": "" + } + }, + "telegram": { + "enabled": false, + "types": 0, + "options": { + "botAPI": "", + "chatId": "", + "sendSilently": false + } + }, + "pushbullet": { + "enabled": false, + "types": 0, + "options": { + "accessToken": "" + } + }, + "pushover": { + "enabled": false, + "types": 0, + "options": { + "accessToken": "", + "userToken": "" + } + }, + "webhook": { + "enabled": false, + "types": 0, + "options": { + "webhookUrl": "", + "jsonPayload": "IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i" + } + }, + "webpush": { + "enabled": false, + "options": {} + }, + "gotify": { + "enabled": false, + "types": 0, + "options": { + "url": "", + "token": "" + } + } + } + }, + "jobs": { + "plex-recently-added-scan": { + "schedule": "0 */5 * * * *" + }, + "plex-full-scan": { + "schedule": "0 0 3 * * *" + }, + "radarr-scan": { + "schedule": "0 0 4 * * *" + }, + "sonarr-scan": { + "schedule": "0 30 4 * * *" + }, + "download-sync": { + "schedule": "0 * * * * *" + }, + "download-sync-reset": { + "schedule": "0 0 1 * * *" + } + } + } diff --git a/cypress/e2e/discover.cy.ts b/cypress/e2e/discover.cy.ts new file mode 100644 index 000000000..3489061b0 --- /dev/null +++ b/cypress/e2e/discover.cy.ts @@ -0,0 +1,210 @@ +const clickFirstTitleCardInSlider = (sliderTitle: string): void => { + cy.contains('.slider-header', sliderTitle) + .next('[data-testid=media-slider]') + .find('[data-testid=title-card]') + .first() + .trigger('mouseover') + .find('[data-testid=title-card-title]') + .invoke('text') + .then((text) => { + cy.contains('.slider-header', sliderTitle) + .next('[data-testid=media-slider]') + .find('[data-testid=title-card]') + .first() + .click(); + cy.get('[data-testid=media-title]').should('contain', text); + }); +}; + +describe('Discover', () => { + beforeEach(() => { + cy.loginAsAdmin(); + }); + + it('loads a trending item', () => { + cy.intercept('/api/v1/discover/trending*').as('getTrending'); + cy.visit('/'); + cy.wait('@getTrending'); + clickFirstTitleCardInSlider('Trending'); + }); + + it('loads popular movies', () => { + cy.intercept('/api/v1/discover/movies*').as('getPopularMovies'); + cy.visit('/'); + cy.wait('@getPopularMovies'); + clickFirstTitleCardInSlider('Popular Movies'); + }); + + it('loads upcoming movies', () => { + cy.intercept('/api/v1/discover/movies/upcoming*').as('getUpcomingMovies'); + cy.visit('/'); + cy.wait('@getUpcomingMovies'); + clickFirstTitleCardInSlider('Upcoming Movies'); + }); + + it('loads popular series', () => { + cy.intercept('/api/v1/discover/tv*').as('getPopularTv'); + cy.visit('/'); + cy.wait('@getPopularTv'); + clickFirstTitleCardInSlider('Popular Series'); + }); + + it('loads upcoming series', () => { + cy.intercept('/api/v1/discover/tv/upcoming*').as('getUpcomingSeries'); + cy.visit('/'); + cy.wait('@getUpcomingSeries'); + clickFirstTitleCardInSlider('Upcoming Series'); + }); + + it('displays error for media with invalid TMDB ID', () => { + cy.intercept('GET', '/api/v1/media?*', { + pageInfo: { pages: 1, pageSize: 20, results: 1, page: 1 }, + results: [ + { + downloadStatus: [], + downloadStatus4k: [], + id: 1922, + mediaType: 'movie', + tmdbId: 998814, + tvdbId: null, + imdbId: null, + status: 5, + status4k: 1, + createdAt: '2022-08-18T18:11:13.000Z', + updatedAt: '2022-08-18T19:56:41.000Z', + lastSeasonChange: '2022-08-18T19:56:41.000Z', + mediaAddedAt: '2022-08-18T19:56:41.000Z', + serviceId: null, + serviceId4k: null, + externalServiceId: null, + externalServiceId4k: null, + externalServiceSlug: null, + externalServiceSlug4k: null, + ratingKey: null, + ratingKey4k: null, + seasons: [], + }, + ], + }).as('getMedia'); + + cy.visit('/'); + cy.wait('@getMedia'); + cy.contains('.slider-header', 'Recently Added') + .next('[data-testid=media-slider]') + .find('[data-testid=title-card]') + .first() + .find('[data-testid=title-card-title]') + .contains('Movie Not Found'); + }); + + it('displays error for request with invalid TMDB ID', () => { + cy.intercept('GET', '/api/v1/request?*', { + pageInfo: { pages: 1, pageSize: 10, results: 1, page: 1 }, + results: [ + { + id: 582, + status: 1, + createdAt: '2022-08-18T18:11:13.000Z', + updatedAt: '2022-08-18T18:11:13.000Z', + type: 'movie', + is4k: false, + serverId: null, + profileId: null, + rootFolder: null, + languageProfileId: null, + tags: null, + media: { + downloadStatus: [], + downloadStatus4k: [], + id: 1922, + mediaType: 'movie', + tmdbId: 998814, + tvdbId: null, + imdbId: null, + status: 2, + status4k: 1, + createdAt: '2022-08-18T18:11:13.000Z', + updatedAt: '2022-08-18T18:11:13.000Z', + lastSeasonChange: '2022-08-18T18:11:13.000Z', + mediaAddedAt: null, + serviceId: null, + serviceId4k: null, + externalServiceId: null, + externalServiceId4k: null, + externalServiceSlug: null, + externalServiceSlug4k: null, + ratingKey: null, + ratingKey4k: null, + }, + seasons: [], + modifiedBy: null, + requestedBy: { + permissions: 4194336, + id: 18, + email: 'friend@seerr.dev', + plexUsername: null, + username: '', + recoveryLinkExpirationDate: null, + userType: 2, + avatar: + 'https://gravatar.com/avatar/c77fdc27cab83732b8623d2ea873d330?default=mm&size=200', + movieQuotaLimit: null, + movieQuotaDays: null, + tvQuotaLimit: null, + tvQuotaDays: null, + createdAt: '2022-08-17T04:55:28.000Z', + updatedAt: '2022-08-17T04:55:28.000Z', + requestCount: 1, + displayName: 'friend@seerr.dev', + }, + seasonCount: 0, + }, + ], + }).as('getRequests'); + + cy.visit('/'); + cy.wait('@getRequests'); + cy.contains('.slider-header', 'Recent Requests') + .next('[data-testid=media-slider]') + .find('[data-testid=request-card]') + .first() + .find('[data-testid=request-card-title]') + .contains('Movie Not Found'); + }); + + it('loads plex watchlist', () => { + cy.intercept('/api/v1/discover/watchlist', { + fixture: 'watchlist.json', + }).as('getWatchlist'); + // Wait for one of the watchlist movies to resolve + cy.intercept('/api/v1/movie/361743').as('getTmdbMovie'); + + cy.visit('/'); + + cy.wait('@getWatchlist'); + + const sliderHeader = cy.contains('.slider-header', 'Your Plex Watchlist'); + + sliderHeader.scrollIntoView(); + + cy.wait('@getTmdbMovie'); + // Wait a little longer to make sure the movie component reloaded + cy.wait(500); + + sliderHeader + .next('[data-testid=media-slider]') + .find('[data-testid=title-card]') + .first() + .trigger('mouseover') + .find('[data-testid=title-card-title]') + .invoke('text') + .then((text) => { + cy.contains('.slider-header', 'Plex Watchlist') + .next('[data-testid=media-slider]') + .find('[data-testid=title-card]') + .first() + .click(); + cy.get('[data-testid=media-title]').should('contain', text); + }); + }); +}); diff --git a/cypress/e2e/login.cy.ts b/cypress/e2e/login.cy.ts new file mode 100644 index 000000000..1c9554174 --- /dev/null +++ b/cypress/e2e/login.cy.ts @@ -0,0 +1,13 @@ +describe('Login Page', () => { + it('succesfully logs in as an admin', () => { + cy.loginAsAdmin(); + cy.visit('/'); + cy.contains('Trending'); + }); + + it('succesfully logs in as a local user', () => { + cy.loginAsUser(); + cy.visit('/'); + cy.contains('Trending'); + }); +}); diff --git a/cypress/e2e/movie-details.cy.ts b/cypress/e2e/movie-details.cy.ts new file mode 100644 index 000000000..1d3ecf3f1 --- /dev/null +++ b/cypress/e2e/movie-details.cy.ts @@ -0,0 +1,12 @@ +describe('Movie Details', () => { + it('loads a movie page', () => { + cy.loginAsAdmin(); + // Try to load minions: rise of gru + cy.visit('/movie/438148'); + + cy.get('[data-testid=media-title]').should( + 'contain', + 'Minions: The Rise of Gru (2022)' + ); + }); +}); diff --git a/cypress/e2e/pull-to-refresh.cy.ts b/cypress/e2e/pull-to-refresh.cy.ts new file mode 100644 index 000000000..d56c55897 --- /dev/null +++ b/cypress/e2e/pull-to-refresh.cy.ts @@ -0,0 +1,25 @@ +describe('Pull To Refresh', () => { + beforeEach(() => { + cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD')); + cy.viewport(390, 844); + cy.visitMobile('/'); + }); + + it('reloads the current page', () => { + cy.wait(500); + + cy.intercept({ + method: 'GET', + url: '/api/v1/*', + }).as('apiCall'); + + cy.get('.searchbar').swipe('bottom', [190, 400]); + + cy.wait('@apiCall').then((interception) => { + assert.isNotNull( + interception.response.body, + 'API was called and received data' + ); + }); + }); +}); diff --git a/cypress/e2e/settings/general-settings.cy.ts b/cypress/e2e/settings/general-settings.cy.ts new file mode 100644 index 000000000..3717f65b0 --- /dev/null +++ b/cypress/e2e/settings/general-settings.cy.ts @@ -0,0 +1,32 @@ +describe('General Settings', () => { + beforeEach(() => { + cy.loginAsAdmin(); + }); + + it('opens the settings page from the home page', () => { + cy.visit('/'); + + cy.get('[data-testid=sidebar-toggle]').click(); + cy.get('[data-testid=sidebar-menu-settings-mobile]').click(); + + cy.get('.heading').should('contain', 'General Settings'); + }); + + it('modifies setting that requires restart', () => { + cy.visit('/settings'); + + cy.get('#trustProxy').click(); + cy.get('form').submit(); + cy.get('[data-testid=modal-title]').should( + 'contain', + 'Server Restart Required' + ); + + cy.get('[data-testid=modal-ok-button]').click(); + cy.get('[data-testid=modal-title]').should('not.exist'); + + cy.get('[type=checkbox]#trustProxy').click(); + cy.get('form').submit(); + cy.get('[data-testid=modal-title]').should('not.exist'); + }); +}); diff --git a/cypress/e2e/tv-details.cy.ts b/cypress/e2e/tv-details.cy.ts new file mode 100644 index 000000000..5b4bd049a --- /dev/null +++ b/cypress/e2e/tv-details.cy.ts @@ -0,0 +1,28 @@ +describe('TV Details', () => { + it('loads a tv details page', () => { + cy.loginAsAdmin(); + // Try to load stranger things + cy.visit('/tv/66732'); + + cy.get('[data-testid=media-title]').should( + 'contain', + 'Stranger Things (2016)' + ); + }); + + it('shows seasons and expands episodes', () => { + cy.loginAsAdmin(); + + // Try to load stranger things + cy.visit('/tv/66732'); + + // intercept request for season info + cy.intercept('/api/v1/tv/66732/season/4').as('season4'); + + cy.contains('Season 4').should('be.visible').scrollIntoView().click(); + + cy.wait('@season4'); + + cy.contains('Chapter Nine').should('be.visible'); + }); +}); diff --git a/cypress/e2e/user/auto-request-settings.cy.ts b/cypress/e2e/user/auto-request-settings.cy.ts new file mode 100644 index 000000000..e7f5727be --- /dev/null +++ b/cypress/e2e/user/auto-request-settings.cy.ts @@ -0,0 +1,74 @@ +const visitUserEditPage = (email: string): void => { + cy.visit('/users'); + + cy.contains('[data-testid=user-list-row]', email).contains('Edit').click(); +}; + +describe('Auto Request Settings', () => { + beforeEach(() => { + cy.loginAsAdmin(); + }); + + it('should not see watchlist sync settings on an account without permissions', () => { + visitUserEditPage(Cypress.env('USER_EMAIL')); + + cy.contains('Auto-Request Movies').should('not.exist'); + cy.contains('Auto-Request Series').should('not.exist'); + }); + + it('should see watchlist sync settings on an admin account', () => { + visitUserEditPage(Cypress.env('ADMIN_EMAIL')); + + cy.contains('Auto-Request Movies').should('exist'); + cy.contains('Auto-Request Series').should('exist'); + }); + + it('should see auto-request settings after being given permission', () => { + visitUserEditPage(Cypress.env('USER_EMAIL')); + + cy.get('[data-testid=settings-nav-desktop').contains('Permissions').click(); + + cy.get('#autorequest').should('not.be.checked').click(); + + cy.intercept('/api/v1/user/*/settings/permissions').as('userPermissions'); + + cy.contains('Save Changes').click(); + + cy.wait('@userPermissions'); + + cy.reload(); + + cy.get('#autorequest').should('be.checked'); + cy.get('#autorequestmovies').should('be.checked'); + cy.get('#autorequesttv').should('be.checked'); + + cy.get('[data-testid=settings-nav-desktop').contains('General').click(); + + cy.contains('Auto-Request Movies').should('exist'); + cy.contains('Auto-Request Series').should('exist'); + + cy.get('#watchlistSyncMovies').should('not.be.checked').click(); + cy.get('#watchlistSyncTv').should('not.be.checked').click(); + + cy.intercept('/api/v1/user/*/settings/main').as('userMain'); + + cy.contains('Save Changes').click(); + + cy.wait('@userMain'); + + cy.reload(); + + cy.get('#watchlistSyncMovies').should('be.checked').click(); + cy.get('#watchlistSyncTv').should('be.checked').click(); + + cy.contains('Save Changes').click(); + + cy.wait('@userMain'); + + cy.get('[data-testid=settings-nav-desktop').contains('Permissions').click(); + + cy.get('#autorequest').should('be.checked').click(); + + cy.contains('Save Changes').click(); + }); +}); diff --git a/cypress/e2e/user/profile.cy.ts b/cypress/e2e/user/profile.cy.ts new file mode 100644 index 000000000..9cc38d887 --- /dev/null +++ b/cypress/e2e/user/profile.cy.ts @@ -0,0 +1,50 @@ +describe('User Profile', () => { + beforeEach(() => { + cy.loginAsAdmin(); + }); + + it('opens user profile page from the home page', () => { + cy.visit('/'); + + cy.get('[data-testid=user-menu]').click(); + cy.get('[data-testid=user-menu-profile]').click(); + + cy.get('h1').should('contain', Cypress.env('ADMIN_EMAIL')); + }); + + it('loads plex watchlist', () => { + cy.intercept('/api/v1/user/[0-9]*/watchlist', { + fixture: 'watchlist.json', + }).as('getWatchlist'); + // Wait for one of the watchlist movies to resolve + cy.intercept('/api/v1/movie/361743').as('getTmdbMovie'); + + cy.visit('/profile'); + + cy.wait('@getWatchlist'); + + const sliderHeader = cy.contains('.slider-header', 'Plex Watchlist'); + + sliderHeader.scrollIntoView(); + + cy.wait('@getTmdbMovie'); + // Wait a little longer to make sure the movie component reloaded + cy.wait(500); + + sliderHeader + .next('[data-testid=media-slider]') + .find('[data-testid=title-card]') + .first() + .trigger('mouseover') + .find('[data-testid=title-card-title]') + .invoke('text') + .then((text) => { + cy.contains('.slider-header', 'Plex Watchlist') + .next('[data-testid=media-slider]') + .find('[data-testid=title-card]') + .first() + .click(); + cy.get('[data-testid=media-title]').should('contain', text); + }); + }); +}); diff --git a/cypress/e2e/user/user-list.cy.ts b/cypress/e2e/user/user-list.cy.ts new file mode 100644 index 000000000..503bd23f1 --- /dev/null +++ b/cypress/e2e/user/user-list.cy.ts @@ -0,0 +1,70 @@ +const testUser = { + displayName: 'Test User', + emailAddress: 'test@seeerr.dev', + password: 'test1234', +}; + +describe('User List', () => { + beforeEach(() => { + cy.loginAsAdmin(); + }); + + it('opens the user list from the home page', () => { + cy.visit('/'); + + cy.get('[data-testid=sidebar-toggle]').click(); + cy.get('[data-testid=sidebar-menu-users-mobile]').click(); + + cy.get('[data-testid=page-header]').should('contain', 'User List'); + }); + + it('can find the admin user and friend user in the user list', () => { + cy.visit('/users'); + + cy.get('[data-testid=user-list-row]').contains(Cypress.env('ADMIN_EMAIL')); + cy.get('[data-testid=user-list-row]').contains(Cypress.env('USER_EMAIL')); + }); + + it('can create a local user', () => { + cy.visit('/users'); + + cy.contains('Create Local User').click(); + + cy.get('[data-testid=modal-title]').should('contain', 'Create Local User'); + + cy.get('#displayName').type(testUser.displayName); + cy.get('#email').type(testUser.emailAddress); + cy.get('#password').type(testUser.password); + + cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user'); + + cy.get('[data-testid=modal-ok-button]').click(); + + cy.wait('@user'); + // Wait a little longer for the user list to fully re-render + cy.wait(1000); + + cy.get('[data-testid=user-list-row]').contains(testUser.emailAddress); + }); + + it('can delete the created local test user', () => { + cy.visit('/users'); + + cy.contains('[data-testid=user-list-row]', testUser.emailAddress) + .contains('Delete') + .click(); + + cy.get('[data-testid=modal-title]').should('contain', `Delete User`); + + cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user'); + + cy.get('[data-testid=modal-ok-button]').should('contain', 'Delete').click(); + + cy.wait('@user'); + cy.wait(1000); + + cy.get('[data-testid=user-list-row]') + .contains(testUser.emailAddress) + .should('not.exist'); + }); +}); diff --git a/cypress/fixtures/watchlist.json b/cypress/fixtures/watchlist.json new file mode 100644 index 000000000..896cef740 --- /dev/null +++ b/cypress/fixtures/watchlist.json @@ -0,0 +1,25 @@ +{ + "page": 1, + "totalPages": 1, + "totalResults": 3, + "results": [ + { + "ratingKey": "5d776be17a53e9001e732ab9", + "title": "Top Gun: Maverick", + "mediaType": "movie", + "tmdbId": 361743 + }, + { + "ratingKey": "5e16338fbc1372003ea68ab3", + "title": "Nope", + "mediaType": "movie", + "tmdbId": 762504 + }, + { + "ratingKey": "5f409b8452f200004161e126", + "title": "Hocus Pocus 2", + "mediaType": "movie", + "tmdbId": 642885 + } + ] +} diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts new file mode 100644 index 000000000..0eb9c869a --- /dev/null +++ b/cypress/support/commands.ts @@ -0,0 +1,35 @@ +/// +import 'cy-mobile-commands'; + +Cypress.Commands.add('login', (email, password) => { + cy.session( + [email, password], + () => { + cy.visit('/login'); + cy.contains('Use your Overseerr account').click(); + + cy.get('[data-testid=email]').type(email); + cy.get('[data-testid=password]').type(password); + + cy.intercept('/api/v1/auth/local').as('localLogin'); + cy.get('[data-testid=local-signin-button]').click(); + + cy.wait('@localLogin'); + + cy.url().should('contain', '/'); + }, + { + validate() { + cy.request('/api/v1/auth/me').its('status').should('eq', 200); + }, + } + ); +}); + +Cypress.Commands.add('loginAsAdmin', () => { + cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD')); +}); + +Cypress.Commands.add('loginAsUser', () => { + cy.login(Cypress.env('USER_EMAIL'), Cypress.env('USER_PASSWORD')); +}); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts new file mode 100644 index 000000000..7a7697cab --- /dev/null +++ b/cypress/support/e2e.ts @@ -0,0 +1,7 @@ +import './commands'; + +before(() => { + if (Cypress.env('SEED_DATABASE')) { + cy.exec('yarn cypress:prepare'); + } +}); diff --git a/cypress/support/index.ts b/cypress/support/index.ts new file mode 100644 index 000000000..857067613 --- /dev/null +++ b/cypress/support/index.ts @@ -0,0 +1,14 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +/// + +declare global { + namespace Cypress { + interface Chainable { + login(email?: string, password?: string): Chainable; + loginAsAdmin(): Chainable; + loginAsUser(): Chainable; + } + } +} + +export {}; diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json new file mode 100644 index 000000000..1b6425b80 --- /dev/null +++ b/cypress/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["es5", "dom"], + "types": ["cypress", "node"], + "resolveJsonModule": true, + "esModuleInterop": true + }, + "include": ["**/*.ts"] +} diff --git a/docs/extending-overseerr/reverse-proxy.md b/docs/extending-overseerr/reverse-proxy.md index 5aa6fd462..84752f7c2 100644 --- a/docs/extending-overseerr/reverse-proxy.md +++ b/docs/extending-overseerr/reverse-proxy.md @@ -138,6 +138,7 @@ location ^~ /overseerr { sub_filter 'href="/"' 'href="/$app"'; sub_filter 'href="/login"' 'href="/$app/login"'; sub_filter 'href:"/"' 'href:"/$app"'; + sub_filter '\/_next' '\/$app\/_next'; sub_filter '/_next' '/$app/_next'; sub_filter '/api/v1' '/$app/api/v1'; sub_filter '/login/plex/loading' '/$app/login/plex/loading'; diff --git a/docs/extending-overseerr/third-party.md b/docs/extending-overseerr/third-party.md index 7ff2bcabf..e1bacfc67 100644 --- a/docs/extending-overseerr/third-party.md +++ b/docs/extending-overseerr/third-party.md @@ -9,7 +9,7 @@ - [LunaSea](https://docs.lunasea.app/modules/overseerr), a self-hosted controller for mobile and macOS - [Requestrr](https://github.com/darkalfx/requestrr/wiki/Configuring-Overseerr), a Discord chatbot - [Doplarr](https://github.com/kiranshila/Doplarr), a Discord request bot -- [Overseerr Assistant](https://github.com/RemiRigal/Overseerr-Assistant), a browser extension for requesting directly from TMDb and IMDb +- [Overseerr Assistant](https://github.com/RemiRigal/Overseerr-Assistant), a browser extension for requesting directly from TMDB and IMDb - [ha-overseerr](https://github.com/vaparr/ha-overseerr), a custom Home Assistant component - [OverCLIrr](https://github.com/WillFantom/OverCLIrr), a command-line tool - [Overseerr Exporter](https://github.com/WillFantom/overseerr-exporter), a Prometheus exporter diff --git a/docs/support/faq.md b/docs/support/faq.md index 56a170941..c638e8636 100644 --- a/docs/support/faq.md +++ b/docs/support/faq.md @@ -45,7 +45,7 @@ Overseerr currently supports the following agents: - New Plex TV - Legacy Plex TV - TheTVDB -- TMDb +- TMDB - [HAMA](https://github.com/ZeroQI/Hama.bundle) Please verify that your library is using one of the agents previously listed. @@ -67,7 +67,7 @@ You can also perform the following to verify the media item has a GUID Overseerr 1. Go to the media item in Plex and **"Get info"** and click on **"View XML"**. 2. Verify that the media item's GUID follows one of the below formats: - 1. TMDb agent `guid="com.plexapp.agents.themoviedb://1705"` + 1. TMDB agent `guid="com.plexapp.agents.themoviedb://1705"` 2. New Plex Movie agent `` 3. TheTVDB agent `guid="com.plexapp.agents.thetvdb://78874/1/1"` 4. Legacy Plex Movie agent `guid="com.plexapp.agents.imdb://tt0765446"` diff --git a/docs/using-overseerr/notifications/webhooks.md b/docs/using-overseerr/notifications/webhooks.md index 37a5c0486..cd2ada314 100644 --- a/docs/using-overseerr/notifications/webhooks.md +++ b/docs/using-overseerr/notifications/webhooks.md @@ -81,7 +81,7 @@ These following special variables are only included in media-related notificatio | Variable | Value | | -------------------- | -------------------------------------------------------------------------------------------------------------- | | `{{media_type}}` | The media type (`movie` or `tv`) | -| `{{media_tmdbid}}` | The media's TMDb ID | +| `{{media_tmdbid}}` | The media's TMDB ID | | `{{media_tvdbid}}` | The media's TheTVDB ID | | `{{media_status}}` | The media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) | | `{{media_status4k}}` | The media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) | diff --git a/merged-prettier-plugin.js b/merged-prettier-plugin.js new file mode 100644 index 000000000..6908488f5 --- /dev/null +++ b/merged-prettier-plugin.js @@ -0,0 +1,21 @@ +/* eslint-disable */ +const tailwind = require('prettier-plugin-tailwindcss'); +const organizeImports = require('prettier-plugin-organize-imports'); + +const combinedFormatter = { + ...tailwind, + parsers: { + ...tailwind.parsers, + ...Object.keys(organizeImports.parsers).reduce((acc, key) => { + acc[key] = { + ...tailwind.parsers[key], + preprocess(code, options) { + return organizeImports.parsers[key].preprocess(code, options); + }, + }; + return acc; + }, {}), + }, +}; + +module.exports = combinedFormatter; diff --git a/next.config.js b/next.config.js index 40d899f7a..bf7c70582 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', @@ -18,4 +21,7 @@ module.exports = { return config; }, + experimental: { + scrollRestoration: true, + }, }; diff --git a/overseerr-api.yml b/overseerr-api.yml index 551f7dd91..33052ad4f 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 @@ -2725,6 +2731,12 @@ paths: nullable: true enum: [debug, info, warn, error] default: debug + - in: query + name: search + schema: + type: string + nullable: true + example: plex responses: '200': description: Server log returned @@ -3394,8 +3406,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 +3771,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 +4709,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 +4776,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 +5688,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 +5710,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 +5775,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 +5803,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 bb7b7d36a..d5fb58f5b 100644 --- a/package.json +++ b/package.json @@ -3,18 +3,25 @@ "version": "1.1.1", "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,145 @@ }, "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", + "cronstrue": "2.11.0", + "csurf": "1.11.0", + "date-fns": "2.29.1", + "email-templates": "9.0.0", + "email-validator": "2.0.4", + "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", + "pulltorefreshjs": "0.1.22", + "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/pulltorefreshjs": "0.1.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", + "cy-mobile-commands": "0.3.0", + "cypress": "10.6.0", + "cz-conventional-changelog": "3.3.0", + "eslint": "8.22.0", + "eslint-config-next": "12.2.5", + "eslint-config-prettier": "8.5.0", + "eslint-plugin-formatjs": "4.1.0", + "eslint-plugin-jsx-a11y": "6.6.1", + "eslint-plugin-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 +188,6 @@ "@commitlint/config-conventional" ] }, - "prettier": { - "singleQuote": true, - "trailingComma": "es5" - }, "release": { "plugins": [ "@semantic-release/commit-analyzer", diff --git a/renovate.json b/renovate.json new file mode 100644 index 000000000..bc68da3a1 --- /dev/null +++ b/renovate.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:js-app", + "group:allNonMajor", + "docker:disableMajor", + "helpers:disableTypesNodeMajor" + ], + "packageRules": [ + { + "matchManagers": ["github-actions"], + "groupName": "GitHub Actions", + "groupSlug": "github-actions" + }, + { + "matchPackageNames": ["node"], + "groupName": "Node.js", + "groupSlug": "node" + } + ] +} diff --git a/server/api/animelist.ts b/server/api/animelist.ts index 20eb2f60e..740f67250 100644 --- a/server/api/animelist.ts +++ b/server/api/animelist.ts @@ -1,8 +1,8 @@ +import logger from '@server/logger'; import axios from 'axios'; -import xml2js from 'xml2js'; import fs, { promises as fsp } from 'fs'; import path from 'path'; -import logger from '../logger'; +import xml2js from 'xml2js'; const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds // originally at https://raw.githubusercontent.com/ScudLee/anime-lists/master/anime-list.xml @@ -14,7 +14,7 @@ const LOCAL_PATH = process.env.CONFIG_DIRECTORY const mappingRegexp = new RegExp(/;[0-9]+-([0-9]+)/g); -// Anime-List xml files are community maintained mappings that Hama agent uses to map AniDB IDs to TVDB/TMDb IDs +// Anime-List xml files are community maintained mappings that Hama agent uses to map AniDB IDs to TVDB/TMDB IDs // https://github.com/Anime-Lists/anime-lists/ interface AnimeMapping { diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts index 2a1d94950..cc1e429ff 100644 --- a/server/api/externalapi.ts +++ b/server/api/externalapi.ts @@ -1,5 +1,7 @@ -import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; -import NodeCache from 'node-cache'; +import type { AxiosInstance, AxiosRequestConfig } from 'axios'; +import axios from 'axios'; +import rateLimit from 'axios-rate-limit'; +import type NodeCache from 'node-cache'; // 5 minute default TTL (in seconds) const DEFAULT_TTL = 300; @@ -10,6 +12,10 @@ const DEFAULT_ROLLING_BUFFER = 10000; interface ExternalAPIOptions { nodeCache?: NodeCache; headers?: Record; + rateLimit?: { + maxRPS: number; + maxRequests: number; + }; } class ExternalAPI { @@ -31,6 +37,14 @@ class ExternalAPI { ...options.headers, }, }); + + if (options.rateLimit) { + this.axios = rateLimit(this.axios, { + maxRequests: options.rateLimit.maxRequests, + maxRPS: options.rateLimit.maxRPS, + }); + } + this.baseUrl = baseUrl; this.cache = options.nodeCache; } diff --git a/server/api/github.ts b/server/api/github.ts index a2a71b41f..86539903b 100644 --- a/server/api/github.ts +++ b/server/api/github.ts @@ -1,5 +1,5 @@ -import cacheManager from '../lib/cache'; -import logger from '../logger'; +import cacheManager from '@server/lib/cache'; +import logger from '@server/logger'; import ExternalAPI from './externalapi'; interface GitHubRelease { diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index 5dd258c4a..79b0778a9 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import axios, { AxiosInstance } from 'axios'; -import logger from '../logger'; +import logger from '@server/logger'; +import type { AxiosInstance } from 'axios'; +import axios from 'axios'; export interface JellyfinUserResponse { Name: string; @@ -16,7 +17,7 @@ export interface JellyfinLoginResponse { } export interface JellyfinUserListResponse { - users: Array; + users: JellyfinUserResponse[]; } export interface JellyfinLibrary { diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index 73278387a..03246c810 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('/'); } @@ -232,6 +232,10 @@ class PlexAPI { uri: `/library/sections/${id}/all?sort=addedAt%3Adesc&addedAt>>=${Math.floor( options.addedAt / 1000 )}`, + extraHeaders: { + 'X-Plex-Container-Start': `0`, + 'X-Plex-Container-Size': `500`, + }, }); return response.MediaContainer.Metadata; diff --git a/server/api/plextv.ts b/server/api/plextv.ts index 1733a85a6..76ee66188 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -1,8 +1,9 @@ -import axios, { AxiosInstance } from 'axios'; +import type { PlexDevice } from '@server/interfaces/api/plexInterfaces'; +import cacheManager from '@server/lib/cache'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; import xml2js from 'xml2js'; -import { PlexDevice } from '../interfaces/api/plexInterfaces'; -import { getSettings } from '../lib/settings'; -import logger from '../logger'; +import ExternalAPI from './externalapi'; interface PlexAccountResponse { user: PlexUser; @@ -111,20 +112,54 @@ interface UsersResponse { }; } -class PlexTvAPI { +interface WatchlistResponse { + MediaContainer: { + totalSize: number; + Metadata?: { + ratingKey: string; + }[]; + }; +} + +interface MetadataResponse { + MediaContainer: { + Metadata: { + ratingKey: string; + type: 'movie' | 'show'; + title: string; + Guid: { + id: `imdb://tt${number}` | `tmdb://${number}` | `tvdb://${number}`; + }[]; + }[]; + }; +} + +export interface PlexWatchlistItem { + ratingKey: string; + tmdbId: number; + tvdbId?: number; + type: 'movie' | 'show'; + title: string; +} + +class PlexTvAPI extends ExternalAPI { private authToken: string; - private axios: AxiosInstance; constructor(authToken: string) { + super( + 'https://plex.tv', + {}, + { + headers: { + 'X-Plex-Token': authToken, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + nodeCache: cacheManager.getCache('plextv').data, + } + ); + this.authToken = authToken; - this.axios = axios.create({ - baseURL: 'https://plex.tv', - headers: { - 'X-Plex-Token': this.authToken, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - }); } public async getDevices(): Promise { @@ -252,6 +287,83 @@ class PlexTvAPI { )) as UsersResponse; return parsedXml; } + + public async getWatchlist({ + offset = 0, + size = 20, + }: { offset?: number; size?: number } = {}): Promise<{ + offset: number; + size: number; + totalSize: number; + items: PlexWatchlistItem[]; + }> { + try { + const response = await this.axios.get( + '/library/sections/watchlist/all', + { + params: { + 'X-Plex-Container-Start': offset, + 'X-Plex-Container-Size': size, + }, + baseURL: 'https://metadata.provider.plex.tv', + } + ); + + const watchlistDetails = await Promise.all( + (response.data.MediaContainer.Metadata ?? []).map( + async (watchlistItem) => { + const detailedResponse = await this.getRolling( + `/library/metadata/${watchlistItem.ratingKey}`, + { + baseURL: 'https://metadata.provider.plex.tv', + } + ); + + const metadata = detailedResponse.MediaContainer.Metadata[0]; + + const tmdbString = metadata.Guid.find((guid) => + guid.id.startsWith('tmdb') + ); + const tvdbString = metadata.Guid.find((guid) => + guid.id.startsWith('tvdb') + ); + + return { + ratingKey: metadata.ratingKey, + // This should always be set? But I guess it also cannot be? + // We will filter out the 0's afterwards + tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0, + tvdbId: tvdbString + ? Number(tvdbString.id.split('//')[1]) + : undefined, + title: metadata.title, + type: metadata.type, + }; + } + ) + ); + + const filteredList = watchlistDetails.filter((detail) => detail.tmdbId); + + return { + offset, + size, + totalSize: response.data.MediaContainer.totalSize, + items: filteredList, + }; + } catch (e) { + logger.error('Failed to retrieve watchlist items', { + label: 'Plex.TV Metadata API', + errorMessage: e.message, + }); + return { + offset, + size, + totalSize: 0, + items: [], + }; + } + } } export default PlexTvAPI; diff --git a/server/api/rottentomatoes.ts b/server/api/rottentomatoes.ts index b9b00e108..e190b7b97 100644 --- a/server/api/rottentomatoes.ts +++ b/server/api/rottentomatoes.ts @@ -1,4 +1,4 @@ -import cacheManager from '../lib/cache'; +import cacheManager from '@server/lib/cache'; import ExternalAPI from './externalapi'; interface RTSearchResult { diff --git a/server/api/servarr/base.ts b/server/api/servarr/base.ts index 9e4559339..2b8ec4cb8 100644 --- a/server/api/servarr/base.ts +++ b/server/api/servarr/base.ts @@ -1,6 +1,7 @@ -import cacheManager, { AvailableCacheIds } from '../../lib/cache'; -import { DVRSettings } from '../../lib/settings'; -import ExternalAPI from '../externalapi'; +import ExternalAPI from '@server/api/externalapi'; +import type { AvailableCacheIds } from '@server/lib/cache'; +import cacheManager from '@server/lib/cache'; +import type { DVRSettings } from '@server/lib/settings'; export interface SystemStatus { version: string; diff --git a/server/api/servarr/radarr.ts b/server/api/servarr/radarr.ts index 7305baf09..1637a8d8e 100644 --- a/server/api/servarr/radarr.ts +++ b/server/api/servarr/radarr.ts @@ -1,4 +1,4 @@ -import logger from '../../logger'; +import logger from '@server/logger'; import ServarrBase from './base'; export interface RadarrMovieOptions { @@ -69,7 +69,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { return response.data[0]; } catch (e) { - logger.error('Error retrieving movie by TMDb ID', { + logger.error('Error retrieving movie by TMDB ID', { label: 'Radarr API', errorMessage: e.message, tmdbId: id, diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index 7440d2786..a5b9c1e8d 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -1,4 +1,4 @@ -import logger from '../../logger'; +import logger from '@server/logger'; import ServarrBase from './base'; interface SonarrSeason { diff --git a/server/api/tautulli.ts b/server/api/tautulli.ts index bb7f37235..0e5e07071 100644 --- a/server/api/tautulli.ts +++ b/server/api/tautulli.ts @@ -1,8 +1,9 @@ -import axios, { AxiosInstance } from 'axios'; +import type { User } from '@server/entity/User'; +import type { TautulliSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import type { AxiosInstance } from 'axios'; +import axios from 'axios'; import { uniqWith } from 'lodash'; -import { User } from '../entity/User'; -import { TautulliSettings } from '../lib/settings'; -import logger from '../logger'; export interface TautulliHistoryRecord { date: number; diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index b5060c030..ea05b8ab9 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -1,7 +1,7 @@ +import ExternalAPI from '@server/api/externalapi'; +import cacheManager from '@server/lib/cache'; import { sortBy } from 'lodash'; -import cacheManager from '../../lib/cache'; -import ExternalAPI from '../externalapi'; -import { +import type { TmdbCollection, TmdbExternalIdResponse, TmdbGenre, @@ -92,6 +92,10 @@ class TheMovieDb extends ExternalAPI { }, { nodeCache: cacheManager.getCache('tmdb').data, + rateLimit: { + maxRequests: 20, + maxRPS: 50, + }, } ); this.region = region; @@ -192,7 +196,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch person details: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`); } }; @@ -214,7 +218,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { throw new Error( - `[TMDb] Failed to fetch person combined credits: ${e.message}` + `[TMDB] Failed to fetch person combined credits: ${e.message}` ); } }; @@ -241,7 +245,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch movie details: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`); } }; @@ -267,7 +271,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`); } }; @@ -293,7 +297,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`); } }; @@ -319,7 +323,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); } } @@ -345,7 +349,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); } } @@ -371,7 +375,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch movies by keyword: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`); } } @@ -398,7 +402,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { throw new Error( - `[TMDb] Failed to fetch TV recommendations: ${e.message}` + `[TMDB] Failed to fetch TV recommendations: ${e.message}` ); } } @@ -422,7 +426,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch TV similar: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch TV similar: ${e.message}`); } } @@ -455,7 +459,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); } }; @@ -488,7 +492,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch discover TV: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch discover TV: ${e.message}`); } }; @@ -514,7 +518,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch upcoming movies: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`); } }; @@ -541,7 +545,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); } }; @@ -564,7 +568,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); } }; @@ -587,7 +591,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); } }; @@ -619,7 +623,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to find by external ID: ${e.message}`); + throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`); } } @@ -657,7 +661,7 @@ class TheMovieDb extends ExternalAPI { throw new Error(`No movie or show returned from API for ID ${imdbId}`); } catch (e) { throw new Error( - `[TMDb] Failed to find media using external IMDb ID: ${e.message}` + `[TMDB] Failed to find media using external IMDb ID: ${e.message}` ); } } @@ -687,7 +691,7 @@ class TheMovieDb extends ExternalAPI { throw new Error(`No show returned from API for ID ${tvdbId}`); } catch (e) { throw new Error( - `[TMDb] Failed to get TV show using the external TVDB ID: ${e.message}` + `[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}` ); } } @@ -711,7 +715,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch collection: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`); } } @@ -727,7 +731,7 @@ class TheMovieDb extends ExternalAPI { return regions; } catch (e) { - throw new Error(`[TMDb] Failed to fetch countries: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch countries: ${e.message}`); } } @@ -743,7 +747,7 @@ class TheMovieDb extends ExternalAPI { return languages; } catch (e) { - throw new Error(`[TMDb] Failed to fetch langauges: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch langauges: ${e.message}`); } } @@ -755,7 +759,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch movie studio: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch movie studio: ${e.message}`); } } @@ -765,7 +769,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch TV network: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch TV network: ${e.message}`); } } @@ -816,7 +820,7 @@ class TheMovieDb extends ExternalAPI { return movieGenres; } catch (e) { - throw new Error(`[TMDb] Failed to fetch movie genres: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch movie genres: ${e.message}`); } } @@ -867,7 +871,7 @@ class TheMovieDb extends ExternalAPI { return tvGenres; } catch (e) { - throw new Error(`[TMDb] Failed to fetch TV genres: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch TV genres: ${e.message}`); } } } diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts index 2282fe052..6d005dc94 100644 --- a/server/api/themoviedb/interfaces.ts +++ b/server/api/themoviedb/interfaces.ts @@ -191,7 +191,7 @@ export interface TmdbVideo { export interface TmdbTvEpisodeResult { id: number; - air_date: string; + air_date: string | null; episode_number: number; name: string; overview: string; @@ -372,7 +372,8 @@ export interface TmdbPersonCombinedCredits { crew: TmdbPersonCreditCrew[]; } -export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult { +export interface TmdbSeasonWithEpisodes + extends Omit { episodes: TmdbTvEpisodeResult[]; external_ids: TmdbExternalIds; } diff --git a/server/constants/media.ts b/server/constants/media.ts index d9ef9e022..de2bf834d 100644 --- a/server/constants/media.ts +++ b/server/constants/media.ts @@ -2,6 +2,7 @@ export enum MediaRequestStatus { PENDING = 1, APPROVED, DECLINED, + FAILED, } export enum MediaType { diff --git a/ormconfig.js b/server/datasource.ts similarity index 64% rename from ormconfig.js rename to server/datasource.ts index 4122f079e..a68392989 100644 --- a/ormconfig.js +++ b/server/datasource.ts @@ -1,4 +1,8 @@ -const devConfig = { +import 'reflect-metadata'; +import type { DataSourceOptions, EntityTarget, Repository } from 'typeorm'; +import { DataSource } from 'typeorm'; + +const devConfig: DataSourceOptions = { type: 'sqlite', database: process.env.CONFIG_DIRECTORY ? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3` @@ -10,31 +14,30 @@ const devConfig = { entities: ['server/entity/**/*.ts'], migrations: ['server/migration/**/*.ts'], subscribers: ['server/subscriber/**/*.ts'], - cli: { - entitiesDir: 'server/entity', - migrationsDir: 'server/migration', - }, }; -const prodConfig = { +const prodConfig: DataSourceOptions = { type: 'sqlite', database: process.env.CONFIG_DIRECTORY ? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3` : 'config/db/db.sqlite3', synchronize: false, + migrationsRun: false, logging: false, enableWAL: true, entities: ['dist/entity/**/*.js'], migrations: ['dist/migration/**/*.js'], - migrationsRun: false, subscribers: ['dist/subscriber/**/*.js'], - cli: { - entitiesDir: 'dist/entity', - migrationsDir: 'dist/migration', - }, }; -const finalConfig = - process.env.NODE_ENV !== 'production' ? devConfig : prodConfig; +const dataSource = new DataSource( + process.env.NODE_ENV !== 'production' ? devConfig : prodConfig +); + +export const getRepository = ( + target: EntityTarget +): Repository => { + return dataSource.getRepository(target); +}; -module.exports = finalConfig; +export default dataSource; diff --git a/server/entity/Issue.ts b/server/entity/Issue.ts index d8e05c565..fae96967d 100644 --- a/server/entity/Issue.ts +++ b/server/entity/Issue.ts @@ -1,3 +1,5 @@ +import type { IssueType } from '@server/constants/issue'; +import { IssueStatus } from '@server/constants/issue'; import { Column, CreateDateColumn, @@ -7,7 +9,6 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; -import { IssueStatus, IssueType } from '../constants/issue'; import IssueComment from './IssueComment'; import Media from './Media'; import { User } from './User'; diff --git a/server/entity/Media.ts b/server/entity/Media.ts index e0cadeef4..48b04c785 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,20 +176,24 @@ 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 { @@ -197,10 +205,16 @@ class Media { ? externalHostname : hostname; if (this.jellyfinMediaId) { - this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`; + this.mediaUrl = new URL( + `/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`, + jellyfinHost + ).href; } if (this.jellyfinMediaId4k) { - this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId4k}&context=home&serverId=${serverId}`; + this.mediaUrl4k = new URL( + `/web/index.html#!/${pageName}?id=${this.jellyfinMediaId4k}&context=home&serverId=${serverId}`, + jellyfinHost + ).href; } } } diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index f7f821156..eefbc11f3 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -1,3 +1,23 @@ +import type { RadarrMovieOptions } from '@server/api/servarr/radarr'; +import RadarrAPI from '@server/api/servarr/radarr'; +import type { + AddSeriesOptions, + SonarrSeries, +} from '@server/api/servarr/sonarr'; +import SonarrAPI from '@server/api/servarr/sonarr'; +import TheMovieDb from '@server/api/themoviedb'; +import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; +import { + MediaRequestStatus, + MediaStatus, + MediaType, +} from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces'; +import notificationManager, { Notification } from '@server/lib/notifications'; +import { Permission } from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; import { isEqual, truncate } from 'lodash'; import { AfterInsert, @@ -6,30 +26,347 @@ import { Column, CreateDateColumn, Entity, - getRepository, ManyToOne, OneToMany, PrimaryGeneratedColumn, RelationCount, UpdateDateColumn, } from 'typeorm'; -import RadarrAPI, { RadarrMovieOptions } from '../api/servarr/radarr'; -import SonarrAPI, { - AddSeriesOptions, - SonarrSeries, -} from '../api/servarr/sonarr'; -import TheMovieDb from '../api/themoviedb'; -import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants'; -import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media'; -import notificationManager, { Notification } from '../lib/notifications'; -import { getSettings } from '../lib/settings'; -import logger from '../logger'; import Media from './Media'; import SeasonRequest from './SeasonRequest'; import { User } from './User'; +export class RequestPermissionError extends Error {} +export class QuotaRestrictedError extends Error {} +export class DuplicateMediaRequestError extends Error {} +export class NoSeasonsAvailableError extends Error {} + +type MediaRequestOptions = { + isAutoRequest?: boolean; +}; + @Entity() export class MediaRequest { + public static async request( + requestBody: MediaRequestBody, + user: User, + options: MediaRequestOptions = {} + ): Promise { + const tmdb = new TheMovieDb(); + const mediaRepository = getRepository(Media); + const requestRepository = getRepository(MediaRequest); + const userRepository = getRepository(User); + + let requestUser = user; + + if ( + requestBody.userId && + !requestUser.hasPermission([ + Permission.MANAGE_USERS, + Permission.MANAGE_REQUESTS, + ]) + ) { + throw new RequestPermissionError( + 'You do not have permission to modify the request user.' + ); + } else if (requestBody.userId) { + requestUser = await userRepository.findOneOrFail({ + where: { id: requestBody.userId }, + }); + } + + if (!requestUser) { + throw new Error('User missing from request context.'); + } + + if ( + requestBody.mediaType === MediaType.MOVIE && + !requestUser.hasPermission( + requestBody.is4k + ? [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE] + : [Permission.REQUEST, Permission.REQUEST_MOVIE], + { + type: 'or', + } + ) + ) { + throw new RequestPermissionError( + `You do not have permission to make ${ + requestBody.is4k ? '4K ' : '' + }movie requests.` + ); + } else if ( + requestBody.mediaType === MediaType.TV && + !requestUser.hasPermission( + requestBody.is4k + ? [Permission.REQUEST_4K, Permission.REQUEST_4K_TV] + : [Permission.REQUEST, Permission.REQUEST_TV], + { + type: 'or', + } + ) + ) { + throw new RequestPermissionError( + `You do not have permission to make ${ + requestBody.is4k ? '4K ' : '' + }series requests.` + ); + } + + const quotas = await requestUser.getQuota(); + + if (requestBody.mediaType === MediaType.MOVIE && quotas.movie.restricted) { + throw new QuotaRestrictedError('Movie Quota exceeded.'); + } else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) { + throw new QuotaRestrictedError('Series Quota exceeded.'); + } + + const tmdbMedia = + requestBody.mediaType === MediaType.MOVIE + ? await tmdb.getMovie({ movieId: requestBody.mediaId }) + : await tmdb.getTvShow({ tvId: requestBody.mediaId }); + + let media = await mediaRepository.findOne({ + where: { + tmdbId: requestBody.mediaId, + mediaType: requestBody.mediaType, + }, + relations: ['requests'], + }); + + if (!media) { + media = new Media({ + tmdbId: tmdbMedia.id, + tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id, + status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, + status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, + mediaType: requestBody.mediaType, + }); + } else { + if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) { + media.status = MediaStatus.PENDING; + } + + if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) { + media.status4k = MediaStatus.PENDING; + } + } + + const existing = await requestRepository + .createQueryBuilder('request') + .leftJoin('request.media', 'media') + .leftJoinAndSelect('request.requestedBy', 'user') + .where('request.is4k = :is4k', { is4k: requestBody.is4k }) + .andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id }) + .andWhere('media.mediaType = :mediaType', { + mediaType: requestBody.mediaType, + }) + .getMany(); + + if (existing && existing.length > 0) { + // If there is an existing movie request that isn't declined, don't allow a new one. + if ( + requestBody.mediaType === MediaType.MOVIE && + existing[0].status !== MediaRequestStatus.DECLINED + ) { + logger.warn('Duplicate request for media blocked', { + tmdbId: tmdbMedia.id, + mediaType: requestBody.mediaType, + is4k: requestBody.is4k, + label: 'Media Request', + }); + + throw new DuplicateMediaRequestError( + 'Request for this media already exists.' + ); + } + + // If an existing auto-request for this media exists from the same user, + // don't allow a new one. + if ( + existing.find( + (r) => r.requestedBy.id === requestUser.id && r.isAutoRequest + ) + ) { + throw new DuplicateMediaRequestError( + 'Auto-request for this media and user already exists.' + ); + } + } + + if (requestBody.mediaType === MediaType.MOVIE) { + await mediaRepository.save(media); + + const request = new MediaRequest({ + type: MediaType.MOVIE, + media, + requestedBy: requestUser, + // If the user is an admin or has the "auto approve" permission, automatically approve the request + status: user.hasPermission( + [ + requestBody.is4k + ? Permission.AUTO_APPROVE_4K + : Permission.AUTO_APPROVE, + requestBody.is4k + ? Permission.AUTO_APPROVE_4K_MOVIE + : Permission.AUTO_APPROVE_MOVIE, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, + modifiedBy: user.hasPermission( + [ + requestBody.is4k + ? Permission.AUTO_APPROVE_4K + : Permission.AUTO_APPROVE, + requestBody.is4k + ? Permission.AUTO_APPROVE_4K_MOVIE + : Permission.AUTO_APPROVE_MOVIE, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? user + : undefined, + is4k: requestBody.is4k, + serverId: requestBody.serverId, + profileId: requestBody.profileId, + rootFolder: requestBody.rootFolder, + tags: requestBody.tags, + isAutoRequest: options.isAutoRequest ?? false, + }); + + await requestRepository.save(request); + return request; + } else { + const tmdbMediaShow = tmdbMedia as Awaited< + ReturnType + >; + const requestedSeasons = + requestBody.seasons === 'all' + ? tmdbMediaShow.seasons + .map((season) => season.season_number) + .filter((sn) => sn > 0) + : (requestBody.seasons as number[]); + let existingSeasons: number[] = []; + + // We need to check existing requests on this title to make sure we don't double up on seasons that were + // already requested. In the case they were, we just throw out any duplicates but still approve the request. + // (Unless there are no seasons, in which case we abort) + if (media.requests) { + existingSeasons = media.requests + .filter( + (request) => + request.is4k === requestBody.is4k && + request.status !== MediaRequestStatus.DECLINED + ) + .reduce((seasons, request) => { + const combinedSeasons = request.seasons.map( + (season) => season.seasonNumber + ); + + return [...seasons, ...combinedSeasons]; + }, [] as number[]); + } + + // We should also check seasons that are available/partially available but don't have existing requests + if (media.seasons) { + existingSeasons = [ + ...existingSeasons, + ...media.seasons + .filter( + (season) => + season[requestBody.is4k ? 'status4k' : 'status'] !== + MediaStatus.UNKNOWN + ) + .map((season) => season.seasonNumber), + ]; + } + + const finalSeasons = requestedSeasons.filter( + (rs) => !existingSeasons.includes(rs) + ); + + if (finalSeasons.length === 0) { + throw new NoSeasonsAvailableError('No seasons available to request'); + } else if ( + quotas.tv.limit && + finalSeasons.length > (quotas.tv.remaining ?? 0) + ) { + throw new QuotaRestrictedError('Series Quota exceeded.'); + } + + await mediaRepository.save(media); + + const request = new MediaRequest({ + type: MediaType.TV, + media, + requestedBy: requestUser, + // If the user is an admin or has the "auto approve" permission, automatically approve the request + status: user.hasPermission( + [ + requestBody.is4k + ? Permission.AUTO_APPROVE_4K + : Permission.AUTO_APPROVE, + requestBody.is4k + ? Permission.AUTO_APPROVE_4K_TV + : Permission.AUTO_APPROVE_TV, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, + modifiedBy: user.hasPermission( + [ + requestBody.is4k + ? Permission.AUTO_APPROVE_4K + : Permission.AUTO_APPROVE, + requestBody.is4k + ? Permission.AUTO_APPROVE_4K_TV + : Permission.AUTO_APPROVE_TV, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? user + : undefined, + is4k: requestBody.is4k, + serverId: requestBody.serverId, + profileId: requestBody.profileId, + rootFolder: requestBody.rootFolder, + languageProfileId: requestBody.languageProfileId, + tags: requestBody.tags, + seasons: finalSeasons.map( + (sn) => + new SeasonRequest({ + seasonNumber: sn, + status: user.hasPermission( + [ + requestBody.is4k + ? Permission.AUTO_APPROVE_4K + : Permission.AUTO_APPROVE, + requestBody.is4k + ? Permission.AUTO_APPROVE_4K_TV + : Permission.AUTO_APPROVE_TV, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, + }) + ), + isAutoRequest: options.isAutoRequest ?? false, + }); + + await requestRepository.save(request); + return request; + } + } + @PrimaryGeneratedColumn() public id: number; @@ -120,6 +457,9 @@ export class MediaRequest { }) public tags?: number[]; + @Column({ default: false }) + public isAutoRequest: boolean; + constructor(init?: Partial) { Object.assign(this, init); } @@ -147,6 +487,10 @@ export class MediaRequest { } this.sendNotification(media, Notification.MEDIA_PENDING); + + if (this.isAutoRequest) { + this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED); + } } } @@ -191,6 +535,14 @@ export class MediaRequest { : Notification.MEDIA_APPROVED : Notification.MEDIA_DECLINED ); + + if ( + this.status === MediaRequestStatus.APPROVED && + autoApproved && + this.isAutoRequest + ) { + this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED); + } } } @@ -207,7 +559,7 @@ export class MediaRequest { const mediaRepository = getRepository(Media); const media = await mediaRepository.findOne({ where: { id: this.media.id }, - relations: ['requests'], + relations: { requests: true }, }); if (!media) { logger.error('Media data not found', { @@ -272,7 +624,7 @@ export class MediaRequest { const mediaRepository = getRepository(Media); const fullMedia = await mediaRepository.findOneOrFail({ where: { id: this.media.id }, - relations: ['requests'], + relations: { requests: true }, }); if ( @@ -452,10 +804,13 @@ export class MediaRequest { await mediaRepository.save(media); }) .catch(async () => { - media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; - await mediaRepository.save(media); + const requestRepository = getRepository(MediaRequest); + + this.status = MediaRequestStatus.FAILED; + requestRepository.save(this); + logger.warn( - 'Something went wrong sending movie request to Radarr, marking status as UNKNOWN', + 'Something went wrong sending movie request to Radarr, marking status as FAILED', { label: 'Media Request', requestId: this.id, @@ -543,7 +898,7 @@ export class MediaRequest { const media = await mediaRepository.findOne({ where: { id: this.media.id }, - relations: ['requests'], + relations: { requests: true }, }); if (!media) { @@ -670,7 +1025,7 @@ export class MediaRequest { // We grab media again here to make sure we have the latest version of it const media = await mediaRepository.findOne({ where: { id: this.media.id }, - relations: ['requests'], + relations: { requests: true }, }); if (!media) { @@ -685,10 +1040,13 @@ export class MediaRequest { await mediaRepository.save(media); }) .catch(async () => { - media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; - await mediaRepository.save(media); + const requestRepository = getRepository(MediaRequest); + + this.status = MediaRequestStatus.FAILED; + requestRepository.save(this); + logger.warn( - 'Something went wrong sending series request to Sonarr, marking status as UNKNOWN', + 'Something went wrong sending series request to Sonarr, marking status as FAILED', { label: 'Media Request', requestId: this.id, @@ -723,6 +1081,7 @@ export class MediaRequest { const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series'; let event: string | undefined; let notifyAdmin = true; + let notifySystem = true; switch (type) { case Notification.MEDIA_APPROVED: @@ -736,6 +1095,13 @@ export class MediaRequest { case Notification.MEDIA_PENDING: event = `New ${this.is4k ? '4K ' : ''}${mediaType} Request`; break; + case Notification.MEDIA_AUTO_REQUESTED: + event = `${ + this.is4k ? '4K ' : '' + }${mediaType} Request Automatically Submitted`; + notifyAdmin = false; + notifySystem = false; + break; case Notification.MEDIA_AUTO_APPROVED: event = `${ this.is4k ? '4K ' : '' @@ -752,6 +1118,7 @@ export class MediaRequest { media, request: this, notifyAdmin, + notifySystem, notifyUser: notifyAdmin ? undefined : this.requestedBy, event, subject: `${movie.title}${ @@ -770,6 +1137,7 @@ export class MediaRequest { media, request: this, notifyAdmin, + notifySystem, notifyUser: notifyAdmin ? undefined : this.requestedBy, event, subject: `${tv.name}${ diff --git a/server/entity/Season.ts b/server/entity/Season.ts index 77f9c7607..44a83d976 100644 --- a/server/entity/Season.ts +++ b/server/entity/Season.ts @@ -1,12 +1,12 @@ +import { MediaStatus } from '@server/constants/media'; import { - Entity, - PrimaryGeneratedColumn, Column, - ManyToOne, CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; -import { MediaStatus } from '../constants/media'; import Media from './Media'; @Entity() diff --git a/server/entity/SeasonRequest.ts b/server/entity/SeasonRequest.ts index f499406c5..f9eeef501 100644 --- a/server/entity/SeasonRequest.ts +++ b/server/entity/SeasonRequest.ts @@ -1,12 +1,12 @@ +import { MediaRequestStatus } from '@server/constants/media'; import { - Entity, - PrimaryGeneratedColumn, Column, CreateDateColumn, - UpdateDateColumn, + Entity, ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, } from 'typeorm'; -import { MediaRequestStatus } from '../constants/media'; import { MediaRequest } from './MediaRequest'; @Entity() diff --git a/server/entity/Session.ts b/server/entity/Session.ts index e7462c195..ddf851a6e 100644 --- a/server/entity/Session.ts +++ b/server/entity/Session.ts @@ -1,5 +1,5 @@ -import { ISession } from 'connect-typeorm'; -import { Index, Column, PrimaryColumn, Entity } from 'typeorm'; +import type { ISession } from 'connect-typeorm'; +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; @Entity() export class Session implements ISession { diff --git a/server/entity/User.ts b/server/entity/User.ts index 7fa6dc67d..b5f781109 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -1,3 +1,13 @@ +import { MediaRequestStatus, MediaType } from '@server/constants/media'; +import { UserType } from '@server/constants/user'; +import { getRepository } from '@server/datasource'; +import type { QuotaResponse } from '@server/interfaces/api/userInterfaces'; +import PreparedEmail from '@server/lib/email'; +import type { PermissionCheckOptions } from '@server/lib/permissions'; +import { hasPermission, Permission } from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { AfterDate } from '@server/utils/dateHelpers'; import bcrypt from 'bcrypt'; import { randomUUID } from 'crypto'; import path from 'path'; @@ -7,8 +17,6 @@ import { Column, CreateDateColumn, Entity, - getRepository, - MoreThan, Not, OneToMany, OneToOne, @@ -16,17 +24,6 @@ import { RelationCount, UpdateDateColumn, } from 'typeorm'; -import { MediaRequestStatus, MediaType } from '../constants/media'; -import { UserType } from '../constants/user'; -import { QuotaResponse } from '../interfaces/api/userInterfaces'; -import PreparedEmail from '../lib/email'; -import { - hasPermission, - Permission, - PermissionCheckOptions, -} from '../lib/permissions'; -import { getSettings } from '../lib/settings'; -import logger from '../logger'; import Issue from './Issue'; import { MediaRequest } from './MediaRequest'; import SeasonRequest from './SeasonRequest'; @@ -270,13 +267,14 @@ export class User { if (movieQuotaDays) { movieDate.setDate(movieDate.getDate() - movieQuotaDays); } - const movieQuotaStartDate = movieDate.toJSON(); const movieQuotaUsed = movieQuotaLimit ? await requestRepository.count({ where: { - requestedBy: this, - createdAt: MoreThan(movieQuotaStartDate), + requestedBy: { + id: this.id, + }, + createdAt: AfterDate(movieDate), type: MediaType.MOVIE, status: Not(MediaRequestStatus.DECLINED), }, diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index 08397b12f..771c382d1 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -1,3 +1,6 @@ +import type { NotificationAgentTypes } from '@server/interfaces/api/userSettingsInterfaces'; +import { hasNotificationType, Notification } from '@server/lib/notifications'; +import { NotificationAgentKey } from '@server/lib/settings'; import { Column, Entity, @@ -5,9 +8,6 @@ import { OneToOne, PrimaryGeneratedColumn, } from 'typeorm'; -import { NotificationAgentTypes } from '../interfaces/api/userSettingsInterfaces'; -import { hasNotificationType, Notification } from '../lib/notifications'; -import { NotificationAgentKey } from '../lib/settings'; import { User } from './User'; export const ALL_NOTIFICATIONS = Object.values(Notification) @@ -57,6 +57,12 @@ export class UserSettings { @Column({ nullable: true }) public telegramSendSilently?: boolean; + @Column({ nullable: true }) + public watchlistSyncMovies?: boolean; + + @Column({ nullable: true }) + public watchlistSyncTv?: boolean; + @Column({ type: 'text', nullable: true, diff --git a/server/index.ts b/server/index.ts index c80530120..615e789bf 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,34 +1,37 @@ +import PlexAPI from '@server/api/plexapi'; +import dataSource, { getRepository } from '@server/datasource'; +import { Session } from '@server/entity/Session'; +import { User } from '@server/entity/User'; +import { startJobs } from '@server/job/schedule'; +import notificationManager from '@server/lib/notifications'; +import DiscordAgent from '@server/lib/notifications/agents/discord'; +import EmailAgent from '@server/lib/notifications/agents/email'; +import GotifyAgent from '@server/lib/notifications/agents/gotify'; +import LunaSeaAgent from '@server/lib/notifications/agents/lunasea'; +import PushbulletAgent from '@server/lib/notifications/agents/pushbullet'; +import PushoverAgent from '@server/lib/notifications/agents/pushover'; +import SlackAgent from '@server/lib/notifications/agents/slack'; +import TelegramAgent from '@server/lib/notifications/agents/telegram'; +import WebhookAgent from '@server/lib/notifications/agents/webhook'; +import WebPushAgent from '@server/lib/notifications/agents/webpush'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import routes from '@server/routes'; +import { getAppVersion } from '@server/utils/appVersion'; +import restartFlag from '@server/utils/restartFlag'; import { getClientIp } from '@supercharge/request-ip'; import { TypeormStore } from 'connect-typeorm/out'; import cookieParser from 'cookie-parser'; import csurf from 'csurf'; -import express, { NextFunction, Request, Response } from 'express'; +import type { NextFunction, Request, Response } from 'express'; +import express from 'express'; import * as OpenApiValidator from 'express-openapi-validator'; -import session, { Store } from 'express-session'; +import type { Store } from 'express-session'; +import session from 'express-session'; import next from 'next'; import path from 'path'; import swaggerUi from 'swagger-ui-express'; -import { createConnection, getRepository } from 'typeorm'; import YAML from 'yamljs'; -import PlexAPI from './api/plexapi'; -import { Session } from './entity/Session'; -import { User } from './entity/User'; -import { startJobs } from './job/schedule'; -import notificationManager from './lib/notifications'; -import DiscordAgent from './lib/notifications/agents/discord'; -import EmailAgent from './lib/notifications/agents/email'; -import GotifyAgent from './lib/notifications/agents/gotify'; -import LunaSeaAgent from './lib/notifications/agents/lunasea'; -import PushbulletAgent from './lib/notifications/agents/pushbullet'; -import PushoverAgent from './lib/notifications/agents/pushover'; -import SlackAgent from './lib/notifications/agents/slack'; -import TelegramAgent from './lib/notifications/agents/telegram'; -import WebhookAgent from './lib/notifications/agents/webhook'; -import WebPushAgent from './lib/notifications/agents/webpush'; -import { getSettings } from './lib/settings'; -import logger from './logger'; -import routes from './routes'; -import { getAppVersion } from './utils/appVersion'; const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml'); @@ -40,7 +43,7 @@ const handle = app.getRequestHandler(); app .prepare() .then(async () => { - const dbConnection = await createConnection(); + const dbConnection = await dataSource.initialize(); // Run migrations in production if (process.env.NODE_ENV === 'production') { @@ -51,6 +54,7 @@ app // Load Settings const settings = getSettings().load(); + restartFlag.initializeSettings(settings.main); // Migrate library types if ( @@ -59,8 +63,8 @@ app ) { const userRepository = getRepository(User); const admin = await userRepository.findOne({ - select: ['id', 'plexToken'], - order: { id: 'ASC' }, + select: { id: true, plexToken: true }, + where: { id: 1 }, }); if (admin) { @@ -87,8 +91,18 @@ app new WebPushAgent(), ]); - // Start Jobs - startJobs(); + const userRepository = getRepository(User); + const totalUsers = await userRepository.count(); + if (totalUsers > 0) { + startJobs(); + } else { + logger.info( + `Skipping starting the scheduled jobs as we have no Plex/Jellyfin/Emby servers setup yet`, + { + label: 'Server', + } + ); + } const server = express(); if (settings.main.trustProxy) { diff --git a/server/interfaces/api/discoverInterfaces.ts b/server/interfaces/api/discoverInterfaces.ts index db90e55d2..89cb7426f 100644 --- a/server/interfaces/api/discoverInterfaces.ts +++ b/server/interfaces/api/discoverInterfaces.ts @@ -3,3 +3,17 @@ export interface GenreSliderItem { name: string; backdrops: string[]; } + +export interface WatchlistItem { + ratingKey: string; + tmdbId: number; + mediaType: 'movie' | 'tv'; + title: string; +} + +export interface WatchlistResponse { + page: number; + totalPages: number; + totalResults: number; + results: WatchlistItem[]; +} diff --git a/server/interfaces/api/issueInterfaces.ts b/server/interfaces/api/issueInterfaces.ts index bd17f1958..e5b3643cd 100644 --- a/server/interfaces/api/issueInterfaces.ts +++ b/server/interfaces/api/issueInterfaces.ts @@ -1,5 +1,5 @@ -import Issue from '../../entity/Issue'; -import { PaginatedResponse } from './common'; +import type Issue from '@server/entity/Issue'; +import type { PaginatedResponse } from './common'; export interface IssueResultsResponse extends PaginatedResponse { results: Issue[]; diff --git a/server/interfaces/api/mediaInterfaces.ts b/server/interfaces/api/mediaInterfaces.ts index d17716d20..263d859ad 100644 --- a/server/interfaces/api/mediaInterfaces.ts +++ b/server/interfaces/api/mediaInterfaces.ts @@ -1,6 +1,6 @@ -import type Media from '../../entity/Media'; -import { User } from '../../entity/User'; -import { PaginatedResponse } from './common'; +import type Media from '@server/entity/Media'; +import type { User } from '@server/entity/User'; +import type { PaginatedResponse } from './common'; export interface MediaResultsResponse extends PaginatedResponse { results: Media[]; diff --git a/server/interfaces/api/personInterfaces.ts b/server/interfaces/api/personInterfaces.ts index 19d3468ce..c52ad0c6a 100644 --- a/server/interfaces/api/personInterfaces.ts +++ b/server/interfaces/api/personInterfaces.ts @@ -1,4 +1,4 @@ -import { PersonCreditCast, PersonCreditCrew } from '../../models/Person'; +import type { PersonCreditCast, PersonCreditCrew } from '@server/models/Person'; export interface PersonCombinedCreditsResponse { id: number; diff --git a/server/interfaces/api/plexInterfaces.ts b/server/interfaces/api/plexInterfaces.ts index 5373cb58a..32be891e9 100644 --- a/server/interfaces/api/plexInterfaces.ts +++ b/server/interfaces/api/plexInterfaces.ts @@ -1,4 +1,4 @@ -import { PlexSettings } from '../../lib/settings'; +import type { PlexSettings } from '@server/lib/settings'; export interface PlexStatus { settings: PlexSettings; diff --git a/server/interfaces/api/requestInterfaces.ts b/server/interfaces/api/requestInterfaces.ts index ca39515bd..89863cb04 100644 --- a/server/interfaces/api/requestInterfaces.ts +++ b/server/interfaces/api/requestInterfaces.ts @@ -1,6 +1,21 @@ +import type { MediaType } from '@server/constants/media'; +import type { MediaRequest } from '@server/entity/MediaRequest'; import type { PaginatedResponse } from './common'; -import type { MediaRequest } from '../../entity/MediaRequest'; export interface RequestResultsResponse extends PaginatedResponse { results: MediaRequest[]; } + +export type MediaRequestBody = { + mediaType: MediaType; + mediaId: number; + tvdbId?: number; + seasons?: number[] | 'all'; + is4k?: boolean; + serverId?: number; + profileId?: number; + rootFolder?: string; + languageProfileId?: number; + userId?: number; + tags?: number[]; +}; diff --git a/server/interfaces/api/serviceInterfaces.ts b/server/interfaces/api/serviceInterfaces.ts index 1188f24c0..3b430b0b5 100644 --- a/server/interfaces/api/serviceInterfaces.ts +++ b/server/interfaces/api/serviceInterfaces.ts @@ -1,5 +1,5 @@ -import { QualityProfile, RootFolder, Tag } from '../../api/servarr/base'; -import { LanguageProfile } from '../../api/servarr/sonarr'; +import type { QualityProfile, RootFolder, Tag } from '@server/api/servarr/base'; +import type { LanguageProfile } from '@server/api/servarr/sonarr'; export interface ServiceCommonServer { id: number; diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index c486a1b46..bafd15b1f 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -59,4 +59,5 @@ export interface StatusResponse { commitTag: string; updateAvailable: boolean; commitsBehind: number; + restartRequired: boolean; } diff --git a/server/interfaces/api/userInterfaces.ts b/server/interfaces/api/userInterfaces.ts index e5f564826..2ac75c5e1 100644 --- a/server/interfaces/api/userInterfaces.ts +++ b/server/interfaces/api/userInterfaces.ts @@ -1,7 +1,7 @@ -import Media from '../../entity/Media'; -import { MediaRequest } from '../../entity/MediaRequest'; -import type { User } from '../../entity/User'; -import { PaginatedResponse } from './common'; +import type Media from '@server/entity/Media'; +import type { MediaRequest } from '@server/entity/MediaRequest'; +import type { User } from '@server/entity/User'; +import type { PaginatedResponse } from './common'; export interface UserResultsResponse extends PaginatedResponse { results: User[]; @@ -23,6 +23,7 @@ export interface QuotaResponse { movie: QuotaStatus; tv: QuotaStatus; } + export interface UserWatchDataResponse { recentlyWatched: Media[]; playCount: number; diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index d0a0ff9f8..e54f0070b 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -1,4 +1,4 @@ -import { NotificationAgentKey } from '../../lib/settings'; +import type { NotificationAgentKey } from '@server/lib/settings'; export interface UserSettingsGeneralResponse { username?: string; @@ -15,6 +15,8 @@ export interface UserSettingsGeneralResponse { globalMovieQuotaLimit?: number; globalTvQuotaLimit?: number; globalTvQuotaDays?: number; + watchlistSyncMovies?: boolean; + watchlistSyncTv?: boolean; } export type NotificationAgentTypes = Record; diff --git a/server/job/jellyfinsync/index.ts b/server/job/jellyfinsync/index.ts index 23843d924..85c8dcc58 100644 --- a/server/job/jellyfinsync/index.ts +++ b/server/job/jellyfinsync/index.ts @@ -1,17 +1,19 @@ +import type { JellyfinLibraryItem } from '@server/api/jellyfin'; +import JellyfinAPI from '@server/api/jellyfin'; +import TheMovieDb from '@server/api/themoviedb'; +import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces'; +import { MediaStatus, MediaType } from '@server/constants/media'; +import { MediaServerType } from '@server/constants/server'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import Season from '@server/entity/Season'; +import { User } from '@server/entity/User'; +import type { Library } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import AsyncLock from '@server/utils/asyncLock'; import { randomUUID as uuid } from 'crypto'; import { uniqWith } from 'lodash'; -import { getRepository } from 'typeorm'; -import JellyfinAPI, { JellyfinLibraryItem } from '../../api/jellyfin'; -import TheMovieDb from '../../api/themoviedb'; -import { TmdbTvDetails } from '../../api/themoviedb/interfaces'; -import { MediaStatus, MediaType } from '../../constants/media'; -import { MediaServerType } from '../../constants/server'; -import Media from '../../entity/Media'; -import Season from '../../entity/Season'; -import { User } from '../../entity/User'; -import { getSettings, Library } from '../../lib/settings'; -import logger from '../../logger'; -import AsyncLock from '../../utils/asyncLock'; const BUNDLE_SIZE = 20; const UPDATE_RATE = 4 * 1000; @@ -552,6 +554,7 @@ class JobJellyfinSync { this.running = true; const userRepository = getRepository(User); const admin = await userRepository.findOne({ + where: { id: 1 }, select: [ 'id', 'jellyfinAuthToken', diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 181d540d3..356c475e5 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 { @@ -14,6 +16,7 @@ interface ScheduledJob { name: string; type: 'process' | 'command'; interval: 'short' | 'long' | 'fixed'; + cronSchedule: string; running?: () => boolean; cancelFn?: () => void; } @@ -31,6 +34,7 @@ export const startJobs = (): void => { name: 'Plex Recently Added Scan', type: 'process', interval: 'short', + cronSchedule: jobs['plex-recently-added-scan'].schedule, job: schedule.scheduleJob( jobs['plex-recently-added-scan'].schedule, () => { @@ -50,6 +54,7 @@ export const startJobs = (): void => { name: 'Plex Full Library Scan', type: 'process', interval: 'long', + cronSchedule: jobs['plex-full-scan'].schedule, job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => { logger.info('Starting scheduled job: Plex Full Library Scan', { label: 'Jobs', @@ -69,6 +74,7 @@ export const startJobs = (): void => { name: 'Jellyfin Recently Added Sync', type: 'process', interval: 'long', + cronSchedule: jobs['jellyfin-recently-added-sync'].schedule, job: schedule.scheduleJob( jobs['jellyfin-recently-added-sync'].schedule, () => { @@ -88,6 +94,7 @@ export const startJobs = (): void => { name: 'Jellyfin Full Library Sync', type: 'process', interval: 'long', + cronSchedule: jobs['jellyfin-full-sync'].schedule, job: schedule.scheduleJob(jobs['jellyfin-full-sync'].schedule, () => { logger.info('Starting scheduled job: Jellyfin Full Sync', { label: 'Jobs', @@ -99,12 +106,28 @@ export const startJobs = (): void => { }); } + // Run watchlist sync every 5 minutes + scheduledJobs.push({ + id: 'plex-watchlist-sync', + name: 'Plex Watchlist Sync', + type: 'process', + interval: 'short', + cronSchedule: jobs['plex-watchlist-sync'].schedule, + 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', name: 'Radarr Scan', type: 'process', interval: 'long', + cronSchedule: jobs['radarr-scan'].schedule, job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => { logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' }); radarrScanner.run(); @@ -119,6 +142,7 @@ export const startJobs = (): void => { name: 'Sonarr Scan', type: 'process', interval: 'long', + cronSchedule: jobs['sonarr-scan'].schedule, job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => { logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' }); sonarrScanner.run(); @@ -133,6 +157,7 @@ export const startJobs = (): void => { name: 'Download Sync', type: 'command', interval: 'fixed', + cronSchedule: jobs['download-sync'].schedule, job: schedule.scheduleJob(jobs['download-sync'].schedule, () => { logger.debug('Starting scheduled job: Download Sync', { label: 'Jobs', @@ -147,6 +172,7 @@ export const startJobs = (): void => { name: 'Download Sync Reset', type: 'command', interval: 'long', + cronSchedule: jobs['download-sync-reset'].schedule, job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => { logger.info('Starting scheduled job: Download Sync Reset', { label: 'Jobs', diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 7782a05a8..e81466629 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -6,7 +6,8 @@ export type AvailableCacheIds = | 'sonarr' | 'rt' | 'github' - | 'plexguid'; + | 'plexguid' + | 'plextv'; const DEFAULT_TTL = 300; const DEFAULT_CHECK_PERIOD = 120; @@ -58,6 +59,10 @@ class CacheManager { stdTtl: 86400 * 7, // 1 week cache checkPeriod: 60 * 30, }), + plextv: new Cache('plextv', 'Plex TV', { + stdTtl: 86400 * 7, // 1 week cache + checkPeriod: 60, + }), }; public getCache(id: AvailableCacheIds): Cache { diff --git a/server/lib/downloadtracker.ts b/server/lib/downloadtracker.ts index c62e189d8..4aef968f1 100644 --- a/server/lib/downloadtracker.ts +++ b/server/lib/downloadtracker.ts @@ -1,9 +1,9 @@ +import RadarrAPI from '@server/api/servarr/radarr'; +import SonarrAPI from '@server/api/servarr/sonarr'; +import { MediaType } from '@server/constants/media'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; import { uniqWith } from 'lodash'; -import RadarrAPI from '../api/servarr/radarr'; -import SonarrAPI from '../api/servarr/sonarr'; -import { MediaType } from '../constants/media'; -import logger from '../logger'; -import { getSettings } from './settings'; export interface DownloadingItem { mediaType: MediaType; diff --git a/server/lib/email/index.ts b/server/lib/email/index.ts index 1274d6a8b..c38892ae2 100644 --- a/server/lib/email/index.ts +++ b/server/lib/email/index.ts @@ -1,7 +1,8 @@ +import type { NotificationAgentEmail } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; import Email from 'email-templates'; import nodemailer from 'nodemailer'; import { URL } from 'url'; -import { getSettings, NotificationAgentEmail } from '../settings'; import { openpgpEncrypt } from './openpgpEncrypt'; class PreparedEmail extends Email { diff --git a/server/lib/email/openpgpEncrypt.ts b/server/lib/email/openpgpEncrypt.ts index c067a7d58..dd320ea38 100644 --- a/server/lib/email/openpgpEncrypt.ts +++ b/server/lib/email/openpgpEncrypt.ts @@ -1,7 +1,8 @@ +import logger from '@server/logger'; import { randomBytes } from 'crypto'; import * as openpgp from 'openpgp'; -import { Transform, TransformCallback } from 'stream'; -import logger from '../../logger'; +import type { TransformCallback } from 'stream'; +import { Transform } from 'stream'; interface EncryptorOptions { signingKey?: string; @@ -26,7 +27,7 @@ class PGPEncryptor extends Transform { // just save the whole message _transform = ( - chunk: any, + chunk: Uint8Array, _encoding: BufferEncoding, callback: TransformCallback ): void => { @@ -184,6 +185,9 @@ class PGPEncryptor extends Transform { } export const openpgpEncrypt = (options: EncryptorOptions) => { + // Disabling this line because I don't want to fix it but I am tired + // of seeing the lint warning + // eslint-disable-next-line @typescript-eslint/no-explicit-any return function (mail: any, callback: () => unknown): void { if (!options.encryptionKeys.length) { setImmediate(callback); diff --git a/server/lib/notifications/agents/agent.ts b/server/lib/notifications/agents/agent.ts index edfa1262d..d2b0b1656 100644 --- a/server/lib/notifications/agents/agent.ts +++ b/server/lib/notifications/agents/agent.ts @@ -1,14 +1,15 @@ -import { Notification } from '..'; -import type Issue from '../../../entity/Issue'; -import IssueComment from '../../../entity/IssueComment'; -import Media from '../../../entity/Media'; -import { MediaRequest } from '../../../entity/MediaRequest'; -import { User } from '../../../entity/User'; -import { NotificationAgentConfig } from '../../settings'; +import type Issue from '@server/entity/Issue'; +import type IssueComment from '@server/entity/IssueComment'; +import type Media from '@server/entity/Media'; +import type { MediaRequest } from '@server/entity/MediaRequest'; +import type { User } from '@server/entity/User'; +import type { NotificationAgentConfig } from '@server/lib/settings'; +import type { Notification } from '..'; export interface NotificationPayload { event?: string; subject: string; + notifySystem: boolean; notifyAdmin: boolean; notifyUser?: User; media?: Media; diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index 321200350..67a278bfb 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -1,19 +1,17 @@ +import { IssueStatus, IssueTypeName } from '@server/constants/issue'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import type { NotificationAgentDiscord } from '@server/lib/settings'; +import { getSettings, NotificationAgentKey } from '@server/lib/settings'; +import logger from '@server/logger'; import axios from 'axios'; -import { getRepository } from 'typeorm'; import { hasNotificationType, Notification, shouldSendAdminNotification, } from '..'; -import { IssueStatus, IssueTypeName } from '../../../constants/issue'; -import { User } from '../../../entity/User'; -import logger from '../../../logger'; -import { - getSettings, - NotificationAgentDiscord, - NotificationAgentKey, -} from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; enum EmbedColors { DEFAULT = 0, @@ -245,7 +243,10 @@ class DiscordAgent ): Promise { const settings = this.getSettings(); - if (!hasNotificationType(type, settings.types ?? 0)) { + if ( + !payload.notifySystem || + !hasNotificationType(type, settings.types ?? 0) + ) { return true; } diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index cbed472fa..59c5b4aa7 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -1,19 +1,17 @@ -import { EmailOptions } from 'email-templates'; +import { 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 { 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 * as EmailValidator from 'email-validator'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; class EmailAgent extends BaseAgent @@ -84,6 +82,11 @@ class EmailAgent is4k ? 'in 4K ' : '' }is pending approval:`; break; + case Notification.MEDIA_AUTO_REQUESTED: + body = `A new request for the following ${mediaType} ${ + is4k ? 'in 4K ' : '' + }was automatically submitted:`; + break; case Notification.MEDIA_APPROVED: body = `Your request for the following ${mediaType} ${ is4k ? 'in 4K ' : '' diff --git a/server/lib/notifications/agents/gotify.ts b/server/lib/notifications/agents/gotify.ts index ecd54ce75..d07caac41 100644 --- a/server/lib/notifications/agents/gotify.ts +++ b/server/lib/notifications/agents/gotify.ts @@ -1,15 +1,17 @@ +import { IssueStatus, IssueTypeName } from '@server/constants/issue'; +import type { NotificationAgentGotify } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; import axios from 'axios'; import { hasNotificationType, Notification } from '..'; -import { IssueStatus, IssueTypeName } from '../../../constants/issue'; -import logger from '../../../logger'; -import { getSettings, NotificationAgentGotify } from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; interface GotifyPayload { title: string; message: string; priority: number; - extras: any; + extras: Record; } class GotifyAgent @@ -115,7 +117,10 @@ class GotifyAgent ): Promise { const settings = this.getSettings(); - if (!hasNotificationType(type, settings.types ?? 0)) { + if ( + !payload.notifySystem || + !hasNotificationType(type, settings.types ?? 0) + ) { return true; } diff --git a/server/lib/notifications/agents/lunasea.ts b/server/lib/notifications/agents/lunasea.ts index 0269e2600..885b038ca 100644 --- a/server/lib/notifications/agents/lunasea.ts +++ b/server/lib/notifications/agents/lunasea.ts @@ -1,10 +1,12 @@ +import { IssueStatus, IssueType } from '@server/constants/issue'; +import { MediaStatus } from '@server/constants/media'; +import type { NotificationAgentLunaSea } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; import axios from 'axios'; import { hasNotificationType, Notification } from '..'; -import { IssueStatus, IssueType } from '../../../constants/issue'; -import { MediaStatus } from '../../../constants/media'; -import logger from '../../../logger'; -import { getSettings, NotificationAgentLunaSea } from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; class LunaSeaAgent extends BaseAgent @@ -85,7 +87,10 @@ class LunaSeaAgent ): Promise { const settings = this.getSettings(); - if (!hasNotificationType(type, settings.types ?? 0)) { + if ( + !payload.notifySystem || + !hasNotificationType(type, settings.types ?? 0) + ) { return true; } diff --git a/server/lib/notifications/agents/pushbullet.ts b/server/lib/notifications/agents/pushbullet.ts index b7bc1919f..eed4fda91 100644 --- a/server/lib/notifications/agents/pushbullet.ts +++ b/server/lib/notifications/agents/pushbullet.ts @@ -1,19 +1,18 @@ +import { IssueStatus, IssueTypeName } from '@server/constants/issue'; +import { MediaStatus } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import type { NotificationAgentPushbullet } from '@server/lib/settings'; +import { getSettings, NotificationAgentKey } from '@server/lib/settings'; +import logger from '@server/logger'; import axios from 'axios'; -import { getRepository } from 'typeorm'; import { hasNotificationType, Notification, shouldSendAdminNotification, } from '..'; -import { IssueStatus, IssueTypeName } from '../../../constants/issue'; -import { User } from '../../../entity/User'; -import logger from '../../../logger'; -import { - getSettings, - NotificationAgentKey, - NotificationAgentPushbullet, -} from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; interface PushbulletPayload { type: string; @@ -54,6 +53,12 @@ class PushbulletAgent let status = ''; switch (type) { + case Notification.MEDIA_AUTO_REQUESTED: + status = + payload.media?.status === MediaStatus.PENDING + ? 'Pending Approval' + : 'Processing'; + break; case Notification.MEDIA_PENDING: status = 'Pending Approval'; break; @@ -106,6 +111,7 @@ class PushbulletAgent // Send system notification if ( + payload.notifySystem && hasNotificationType(type, settings.types ?? 0) && settings.enabled && settings.options.accessToken diff --git a/server/lib/notifications/agents/pushover.ts b/server/lib/notifications/agents/pushover.ts index f8364c3f2..d8deb1bdc 100644 --- a/server/lib/notifications/agents/pushover.ts +++ b/server/lib/notifications/agents/pushover.ts @@ -1,19 +1,18 @@ +import { IssueStatus, IssueTypeName } from '@server/constants/issue'; +import { MediaStatus } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import type { NotificationAgentPushover } from '@server/lib/settings'; +import { getSettings, NotificationAgentKey } from '@server/lib/settings'; +import logger from '@server/logger'; import axios from 'axios'; -import { getRepository } from 'typeorm'; import { hasNotificationType, Notification, shouldSendAdminNotification, } from '..'; -import { IssueStatus, IssueTypeName } from '../../../constants/issue'; -import { User } from '../../../entity/User'; -import logger from '../../../logger'; -import { - getSettings, - NotificationAgentKey, - NotificationAgentPushover, -} from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; interface PushoverPayload { token: string; @@ -63,6 +62,12 @@ class PushoverAgent let status = ''; switch (type) { + case Notification.MEDIA_AUTO_REQUESTED: + status = + payload.media?.status === MediaStatus.PENDING + ? 'Pending Approval' + : 'Processing'; + break; case Notification.MEDIA_PENDING: status = 'Pending Approval'; break; @@ -137,6 +142,7 @@ class PushoverAgent // Send system notification if ( + payload.notifySystem && hasNotificationType(type, settings.types ?? 0) && settings.enabled && settings.options.accessToken && diff --git a/server/lib/notifications/agents/slack.ts b/server/lib/notifications/agents/slack.ts index ca10c269c..9447cda35 100644 --- a/server/lib/notifications/agents/slack.ts +++ b/server/lib/notifications/agents/slack.ts @@ -1,9 +1,11 @@ +import { IssueStatus, IssueTypeName } from '@server/constants/issue'; +import type { NotificationAgentSlack } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; import axios from 'axios'; import { hasNotificationType, Notification } from '..'; -import { IssueStatus, IssueTypeName } from '../../../constants/issue'; -import logger from '../../../logger'; -import { getSettings, NotificationAgentSlack } from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; interface EmbedField { type: 'plain_text' | 'mrkdwn'; @@ -223,7 +225,10 @@ class SlackAgent ): Promise { const settings = this.getSettings(); - if (!hasNotificationType(type, settings.types ?? 0)) { + if ( + !payload.notifySystem || + !hasNotificationType(type, settings.types ?? 0) + ) { return true; } diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts index 3450a3c2a..7d7062122 100644 --- a/server/lib/notifications/agents/telegram.ts +++ b/server/lib/notifications/agents/telegram.ts @@ -1,19 +1,18 @@ +import { IssueStatus, IssueTypeName } from '@server/constants/issue'; +import { MediaStatus } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import type { NotificationAgentTelegram } from '@server/lib/settings'; +import { getSettings, NotificationAgentKey } from '@server/lib/settings'; +import logger from '@server/logger'; import axios from 'axios'; -import { getRepository } from 'typeorm'; import { hasNotificationType, Notification, shouldSendAdminNotification, } from '..'; -import { IssueStatus, IssueTypeName } from '../../../constants/issue'; -import { User } from '../../../entity/User'; -import logger from '../../../logger'; -import { - getSettings, - NotificationAgentKey, - NotificationAgentTelegram, -} from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; interface TelegramMessagePayload { text: string; @@ -81,6 +80,12 @@ class TelegramAgent let status = ''; switch (type) { + case Notification.MEDIA_AUTO_REQUESTED: + status = + payload.media?.status === MediaStatus.PENDING + ? 'Pending Approval' + : 'Processing'; + break; case Notification.MEDIA_PENDING: status = 'Pending Approval'; break; @@ -159,6 +164,7 @@ class TelegramAgent // Send system notification if ( + payload.notifySystem && hasNotificationType(type, settings.types ?? 0) && settings.options.chatId ) { diff --git a/server/lib/notifications/agents/webhook.ts b/server/lib/notifications/agents/webhook.ts index ba2bf5e59..461cd37fd 100644 --- a/server/lib/notifications/agents/webhook.ts +++ b/server/lib/notifications/agents/webhook.ts @@ -1,11 +1,13 @@ +import { IssueStatus, IssueType } from '@server/constants/issue'; +import { MediaStatus } from '@server/constants/media'; +import type { NotificationAgentWebhook } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; import axios from 'axios'; import { get } from 'lodash'; import { hasNotificationType, Notification } from '..'; -import { IssueStatus, IssueType } from '../../../constants/issue'; -import { MediaStatus } from '../../../constants/media'; -import logger from '../../../logger'; -import { getSettings, NotificationAgentWebhook } from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; type KeyMapFunction = ( payload: NotificationPayload, @@ -162,7 +164,10 @@ class WebhookAgent ): Promise { const settings = this.getSettings(); - if (!hasNotificationType(type, settings.types ?? 0)) { + if ( + !payload.notifySystem || + !hasNotificationType(type, settings.types ?? 0) + ) { return true; } diff --git a/server/lib/notifications/agents/webpush.ts b/server/lib/notifications/agents/webpush.ts index c87d9496c..275a77e8e 100644 --- a/server/lib/notifications/agents/webpush.ts +++ b/server/lib/notifications/agents/webpush.ts @@ -1,17 +1,15 @@ -import { getRepository } from 'typeorm'; +import { IssueType, IssueTypeName } from '@server/constants/issue'; +import { MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import { UserPushSubscription } from '@server/entity/UserPushSubscription'; +import type { NotificationAgentConfig } from '@server/lib/settings'; +import { getSettings, NotificationAgentKey } from '@server/lib/settings'; +import logger from '@server/logger'; import webpush from 'web-push'; import { Notification, shouldSendAdminNotification } from '..'; -import { IssueType, IssueTypeName } from '../../../constants/issue'; -import { MediaType } from '../../../constants/media'; -import { User } from '../../../entity/User'; -import { UserPushSubscription } from '../../../entity/UserPushSubscription'; -import logger from '../../../logger'; -import { - getSettings, - NotificationAgentConfig, - NotificationAgentKey, -} from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; interface PushNotificationPayload { notificationType: string; @@ -59,6 +57,11 @@ class WebPushAgent case Notification.TEST_NOTIFICATION: message = payload.message; break; + case Notification.MEDIA_AUTO_REQUESTED: + message = `Automatically submitted a new ${ + is4k ? '4K ' : '' + }${mediaType} request.`; + break; case Notification.MEDIA_APPROVED: message = `Your ${ is4k ? '4K ' : '' @@ -160,7 +163,7 @@ class WebPushAgent true) ) { const notifySubs = await userPushSubRepository.find({ - where: { user: payload.notifyUser.id }, + where: { user: { id: payload.notifyUser.id } }, }); pushSubs.push(...notifySubs); diff --git a/server/lib/notifications/index.ts b/server/lib/notifications/index.ts index b8111d02f..71aea8fe9 100644 --- a/server/lib/notifications/index.ts +++ b/server/lib/notifications/index.ts @@ -1,6 +1,6 @@ -import { User } from '../../entity/User'; -import logger from '../../logger'; -import { Permission } from '../permissions'; +import type { User } from '@server/entity/User'; +import { Permission } from '@server/lib/permissions'; +import logger from '@server/logger'; import type { NotificationAgent, NotificationPayload } from './agents/agent'; export enum Notification { @@ -16,6 +16,7 @@ export enum Notification { ISSUE_COMMENT = 512, ISSUE_RESOLVED = 1024, ISSUE_REOPENED = 2048, + MEDIA_AUTO_REQUESTED = 4096, } export const hasNotificationType = ( diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts index 95160d380..4a4a90d84 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -22,6 +22,11 @@ export enum Permission { MANAGE_ISSUES = 1048576, VIEW_ISSUES = 2097152, CREATE_ISSUES = 4194304, + AUTO_REQUEST = 8388608, + AUTO_REQUEST_MOVIE = 16777216, + AUTO_REQUEST_TV = 33554432, + RECENT_VIEW = 67108864, + WATCHLIST_VIEW = 134217728, } export interface PermissionCheckOptions { diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts index f76ea92b0..f0f3db7e6 100644 --- a/server/lib/scanners/baseScanner.ts +++ b/server/lib/scanners/baseScanner.ts @@ -1,12 +1,12 @@ +import TheMovieDb from '@server/api/themoviedb'; +import { MediaStatus, MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import Season from '@server/entity/Season'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import AsyncLock from '@server/utils/asyncLock'; import { randomUUID } from 'crypto'; -import { getRepository } from 'typeorm'; -import TheMovieDb from '../../api/themoviedb'; -import { MediaStatus, MediaType } from '../../constants/media'; -import Media from '../../entity/Media'; -import Season from '../../entity/Season'; -import logger from '../../logger'; -import AsyncLock from '../../utils/asyncLock'; -import { getSettings } from '../settings'; // Default scan rates (can be overidden) const BUNDLE_SIZE = 20; @@ -210,7 +210,7 @@ class BaseScanner { } /** - * processShow takes a TMDb ID and an array of ProcessableSeasons, which + * processShow takes a TMDB ID and an array of ProcessableSeasons, which * should include the total episodes a sesaon has + the total available * episodes that each season currently has. Unlike processMovie, this method * does not take an `is4k` option. We handle both the 4k _and_ non 4k status diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts index cd8dbd76a..73e4d9b26 100644 --- a/server/lib/scanners/plex/index.ts +++ b/server/lib/scanners/plex/index.ts @@ -1,17 +1,20 @@ -import { uniqWith } from 'lodash'; -import { getRepository } from 'typeorm'; -import animeList from '../../../api/animelist'; -import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../../api/plexapi'; -import { TmdbTvDetails } from '../../../api/themoviedb/interfaces'; -import { User } from '../../../entity/User'; -import cacheManager from '../../cache'; -import { getSettings, Library } from '../../settings'; -import BaseScanner, { +import animeList from '@server/api/animelist'; +import type { PlexLibraryItem, PlexMetadata } from '@server/api/plexapi'; +import PlexAPI from '@server/api/plexapi'; +import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import cacheManager from '@server/lib/cache'; +import type { MediaIds, ProcessableSeason, RunnableScanner, StatusBase, -} from '../baseScanner'; +} from '@server/lib/scanners/baseScanner'; +import BaseScanner from '@server/lib/scanners/baseScanner'; +import type { Library } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import { uniqWith } from 'lodash'; const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/); const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/); @@ -59,8 +62,8 @@ class PlexScanner try { const userRepository = getRepository(User); const admin = await userRepository.findOne({ - select: ['id', 'plexToken'], - order: { id: 'ASC' }, + select: { id: true, plexToken: true }, + where: { id: 1 }, }); if (!admin) { @@ -141,7 +144,9 @@ class PlexScanner 'info' ); } catch (e) { - this.log('Scan interrupted', 'error', { errorMessage: e.message }); + this.log('Scan interrupted', 'error', { + errorMessage: e.message, + }); } finally { this.endRun(sessionId); } @@ -369,7 +374,7 @@ class PlexScanner } }); - // If we got an IMDb ID, but no TMDb ID, lookup the TMDb ID with the IMDb ID + // If we got an IMDb ID, but no TMDB ID, lookup the TMDB ID with the IMDb ID if (mediaIds.imdbId && !mediaIds.tmdbId) { const tmdbMedia = await this.tmdb.getMediaByImdbId({ imdbId: mediaIds.imdbId, @@ -390,7 +395,7 @@ class PlexScanner }); mediaIds.tmdbId = tmdbMedia.id; } - // Check if the agent is TMDb + // Check if the agent is TMDB } else if (plexitem.guid.match(tmdbRegex)) { const tmdbMatch = plexitem.guid.match(tmdbRegex); if (tmdbMatch) { @@ -409,7 +414,7 @@ class PlexScanner mediaIds.tvdbId = Number(matchedtvdb[1]); mediaIds.tmdbId = show.id; } - // Check if the agent (for shows) is TMDb + // Check if the agent (for shows) is TMDB } else if (plexitem.guid.match(tmdbShowRegex)) { const matchedtmdb = plexitem.guid.match(tmdbShowRegex); if (matchedtmdb) { @@ -484,10 +489,10 @@ class PlexScanner } if (!mediaIds.tmdbId) { - throw new Error('Unable to find TMDb ID'); + throw new Error('Unable to find TMDB ID'); } - // We check above if we have the TMDb ID, so we can safely assert the type below + // We check above if we have the TMDB ID, so we can safely assert the type below return mediaIds as MediaIds; } diff --git a/server/lib/scanners/radarr/index.ts b/server/lib/scanners/radarr/index.ts index 5f47b9d97..bc299d7b1 100644 --- a/server/lib/scanners/radarr/index.ts +++ b/server/lib/scanners/radarr/index.ts @@ -1,7 +1,13 @@ +import type { RadarrMovie } from '@server/api/servarr/radarr'; +import RadarrAPI from '@server/api/servarr/radarr'; +import type { + RunnableScanner, + StatusBase, +} from '@server/lib/scanners/baseScanner'; +import BaseScanner from '@server/lib/scanners/baseScanner'; +import type { RadarrSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; import { uniqWith } from 'lodash'; -import RadarrAPI, { RadarrMovie } from '../../../api/servarr/radarr'; -import { getSettings, RadarrSettings } from '../../settings'; -import BaseScanner, { RunnableScanner, StatusBase } from '../baseScanner'; type SyncStatus = StatusBase & { currentServer: RadarrSettings; diff --git a/server/lib/scanners/sonarr/index.ts b/server/lib/scanners/sonarr/index.ts index 044f74ec7..3256c9482 100644 --- a/server/lib/scanners/sonarr/index.ts +++ b/server/lib/scanners/sonarr/index.ts @@ -1,14 +1,17 @@ -import { uniqWith } from 'lodash'; -import { getRepository } from 'typeorm'; -import SonarrAPI, { SonarrSeries } from '../../../api/servarr/sonarr'; -import { TmdbTvDetails } from '../../../api/themoviedb/interfaces'; -import Media from '../../../entity/Media'; -import { getSettings, SonarrSettings } from '../../settings'; -import BaseScanner, { +import type { SonarrSeries } from '@server/api/servarr/sonarr'; +import SonarrAPI from '@server/api/servarr/sonarr'; +import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import type { ProcessableSeason, RunnableScanner, StatusBase, -} from '../baseScanner'; +} from '@server/lib/scanners/baseScanner'; +import BaseScanner from '@server/lib/scanners/baseScanner'; +import type { SonarrSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import { uniqWith } from 'lodash'; type SyncStatus = StatusBase & { currentServer: SonarrSettings; diff --git a/server/lib/search.ts b/server/lib/search.ts index c625f512d..be9ee3ae8 100644 --- a/server/lib/search.ts +++ b/server/lib/search.ts @@ -1,5 +1,5 @@ -import TheMovieDb from '../api/themoviedb'; -import { +import TheMovieDb from '@server/api/themoviedb'; +import type { TmdbMovieDetails, TmdbMovieResult, TmdbPersonDetails, @@ -9,13 +9,17 @@ import { TmdbSearchTvResponse, TmdbTvDetails, TmdbTvResult, -} from '../api/themoviedb/interfaces'; +} from '@server/api/themoviedb/interfaces'; import { mapMovieDetailsToResult, mapPersonDetailsToResult, mapTvDetailsToResult, -} from '../models/Search'; -import { isMovie, isMovieDetails, isTvDetails } from '../utils/typeHelpers'; +} from '@server/models/Search'; +import { + isMovie, + isMovieDetails, + isTvDetails, +} from '@server/utils/typeHelpers'; interface SearchProvider { pattern: RegExp; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 53fe864c1..29e2fcf13 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -1,9 +1,9 @@ +import { MediaServerType } from '@server/constants/server'; import { randomUUID } from 'crypto'; import fs from 'fs'; import { merge } from 'lodash'; import path from 'path'; import webpush from 'web-push'; -import { MediaServerType } from '../constants/server'; import { Permission } from './permissions'; export interface Library { @@ -257,6 +257,7 @@ interface JobSettings { export type JobId = | 'plex-recently-added-scan' | 'plex-full-scan' + | 'plex-watchlist-sync' | 'radarr-scan' | 'sonarr-scan' | 'download-sync' @@ -424,6 +425,9 @@ class Settings { 'plex-full-scan': { schedule: '0 0 3 * * *', }, + 'plex-watchlist-sync': { + schedule: '0 */10 * * * *', + }, 'radarr-scan': { schedule: '0 0 4 * * *', }, diff --git a/server/lib/watchlistsync.ts b/server/lib/watchlistsync.ts new file mode 100644 index 000000000..46147f3fc --- /dev/null +++ b/server/lib/watchlistsync.ts @@ -0,0 +1,163 @@ +import PlexTvAPI from '@server/api/plextv'; +import { MediaStatus, MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { + DuplicateMediaRequestError, + MediaRequest, + NoSeasonsAvailableError, + QuotaRestrictedError, + RequestPermissionError, +} from '@server/entity/MediaRequest'; +import { User } from '@server/entity/User'; +import logger from '@server/logger'; +import { Permission } from './permissions'; + +class WatchlistSync { + public async syncWatchlist() { + const userRepository = getRepository(User); + + // Get users who actually have plex tokens + const users = await userRepository + .createQueryBuilder('user') + .addSelect('user.plexToken') + .leftJoinAndSelect('user.settings', 'settings') + .where("user.plexToken != ''") + .getMany(); + + for (const user of users) { + await this.syncUserWatchlist(user); + } + } + + private async syncUserWatchlist(user: User) { + if (!user.plexToken) { + logger.warn('Skipping user watchlist sync for user without plex token', { + label: 'Plex Watchlist Sync', + user: user.displayName, + }); + return; + } + + if ( + !user.hasPermission( + [ + Permission.AUTO_REQUEST, + Permission.AUTO_REQUEST_MOVIE, + Permission.AUTO_APPROVE_TV, + ], + { type: 'or' } + ) + ) { + return; + } + + if ( + !user.settings?.watchlistSyncMovies && + !user.settings?.watchlistSyncTv + ) { + // Skip sync if user settings have it disabled + return; + } + + const plexTvApi = new PlexTvAPI(user.plexToken); + + const response = await plexTvApi.getWatchlist({ size: 200 }); + + const mediaItems = await Media.getRelatedMedia( + response.items.map((i) => i.tmdbId) + ); + + const unavailableItems = response.items.filter( + // If we can find watchlist items in our database that are also available, we should exclude them + (i) => + !mediaItems.find( + (m) => + m.tmdbId === i.tmdbId && + ((m.status !== MediaStatus.UNKNOWN && m.mediaType === 'movie') || + (m.mediaType === 'tv' && m.status === MediaStatus.AVAILABLE)) + ) + ); + + await Promise.all( + unavailableItems.map(async (mediaItem) => { + try { + logger.info("Creating media request from user's Plex Watchlist", { + label: 'Watchlist Sync', + userId: user.id, + mediaTitle: mediaItem.title, + }); + + if (mediaItem.type === 'show' && !mediaItem.tvdbId) { + throw new Error('Missing TVDB ID from Plex Metadata'); + } + + // Check if they have auto-request permissons and watchlist sync + // enabled for the media type + if ( + ((!user.hasPermission( + [Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_MOVIE], + { type: 'or' } + ) || + !user.settings?.watchlistSyncMovies) && + mediaItem.type === 'movie') || + ((!user.hasPermission( + [Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_TV], + { type: 'or' } + ) || + !user.settings?.watchlistSyncTv) && + mediaItem.type === 'show') + ) { + return; + } + + await MediaRequest.request( + { + mediaId: mediaItem.tmdbId, + mediaType: + mediaItem.type === 'show' ? MediaType.TV : MediaType.MOVIE, + seasons: mediaItem.type === 'show' ? 'all' : undefined, + tvdbId: mediaItem.tvdbId, + is4k: false, + }, + user, + { isAutoRequest: true } + ); + } catch (e) { + if (!(e instanceof Error)) { + return; + } + + switch (e.constructor) { + // During watchlist sync, these errors aren't necessarily + // a problem with Overseerr. Since we are auto syncing these constantly, it's + // possible they are unexpectedly at their quota limit, for example. So we'll + // instead log these as debug messages. + case RequestPermissionError: + case DuplicateMediaRequestError: + case QuotaRestrictedError: + case NoSeasonsAvailableError: + logger.debug('Failed to create media request from watchlist', { + label: 'Watchlist Sync', + userId: user.id, + mediaTitle: mediaItem.title, + errorMessage: e.message, + }); + break; + default: + logger.error('Failed to create media request from watchlist', { + label: 'Watchlist Sync', + userId: user.id, + mediaTitle: mediaItem.title, + errorMessage: e.message, + }); + } + } + }) + ); + } +} + +const watchlistSync = new WatchlistSync(); + +export default watchlistSync; diff --git a/server/logger.ts b/server/logger.ts index 4f736e4ab..d5809a0ed 100644 --- a/server/logger.ts +++ b/server/logger.ts @@ -26,7 +26,7 @@ const hformat = winston.format.printf( ); const logger = winston.createLogger({ - level: process.env.LOG_LEVEL || 'debug', + level: process.env.LOG_LEVEL?.toLowerCase() || 'debug', format: winston.format.combine( winston.format.splat(), winston.format.timestamp(), diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 68869222f..326d460d8 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -1,11 +1,14 @@ -import { getRepository } from 'typeorm'; -import { User } from '../entity/User'; -import { Permission, PermissionCheckOptions } from '../lib/permissions'; -import { getSettings } from '../lib/settings'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import type { + Permission, + PermissionCheckOptions, +} from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; export const checkUser: Middleware = async (req, _res, next) => { const settings = getSettings(); - let user: User | undefined; + let user: User | undefined | null; if (req.header('X-API-Key') === settings.main.apiKey) { const userRepository = getRepository(User); diff --git a/server/migration/1603944374840-InitialMigration.ts b/server/migration/1603944374840-InitialMigration.ts index 73640565c..db71471ae 100644 --- a/server/migration/1603944374840-InitialMigration.ts +++ b/server/migration/1603944374840-InitialMigration.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class InitialMigration1603944374840 implements MigrationInterface { name = 'InitialMigration1603944374840'; diff --git a/server/migration/1605085519544-SeasonStatus.ts b/server/migration/1605085519544-SeasonStatus.ts index bcff6f609..059c6bf51 100644 --- a/server/migration/1605085519544-SeasonStatus.ts +++ b/server/migration/1605085519544-SeasonStatus.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class SeasonStatus1605085519544 implements MigrationInterface { name = 'SeasonStatus1605085519544'; diff --git a/server/migration/1606730060700-CascadeMigration.ts b/server/migration/1606730060700-CascadeMigration.ts index 341bc00b3..3b1ae0702 100644 --- a/server/migration/1606730060700-CascadeMigration.ts +++ b/server/migration/1606730060700-CascadeMigration.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class CascadeMigration1606730060700 implements MigrationInterface { name = 'CascadeMigration1606730060700'; diff --git a/server/migration/1607928251245-DropImdbIdConstraint.ts b/server/migration/1607928251245-DropImdbIdConstraint.ts index 97baa861a..f602ea7fa 100644 --- a/server/migration/1607928251245-DropImdbIdConstraint.ts +++ b/server/migration/1607928251245-DropImdbIdConstraint.ts @@ -1,4 +1,5 @@ -import { MigrationInterface, QueryRunner, TableUnique } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; +import { TableUnique } from 'typeorm'; export class DropImdbIdConstraint1607928251245 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1608217312474-AddUserRequestDeleteCascades.ts b/server/migration/1608217312474-AddUserRequestDeleteCascades.ts index e2aa88653..622a2c90e 100644 --- a/server/migration/1608217312474-AddUserRequestDeleteCascades.ts +++ b/server/migration/1608217312474-AddUserRequestDeleteCascades.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserRequestDeleteCascades1608219049304 implements MigrationInterface diff --git a/server/migration/1608477467935-AddLastSeasonChangeMedia.ts b/server/migration/1608477467935-AddLastSeasonChangeMedia.ts index fba7af7f3..e5ab02506 100644 --- a/server/migration/1608477467935-AddLastSeasonChangeMedia.ts +++ b/server/migration/1608477467935-AddLastSeasonChangeMedia.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddLastSeasonChangeMedia1608477467935 implements MigrationInterface diff --git a/server/migration/1608477467936-ForceDropImdbUniqueConstraint.ts b/server/migration/1608477467936-ForceDropImdbUniqueConstraint.ts index 6a109e4d1..d54c450e4 100644 --- a/server/migration/1608477467936-ForceDropImdbUniqueConstraint.ts +++ b/server/migration/1608477467936-ForceDropImdbUniqueConstraint.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class ForceDropImdbUniqueConstraint1608477467935 implements MigrationInterface diff --git a/server/migration/1609236552057-RemoveTmdbIdUniqueConstraint.ts b/server/migration/1609236552057-RemoveTmdbIdUniqueConstraint.ts index 2cd5415e7..500568927 100644 --- a/server/migration/1609236552057-RemoveTmdbIdUniqueConstraint.ts +++ b/server/migration/1609236552057-RemoveTmdbIdUniqueConstraint.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class RemoveTmdbIdUniqueConstraint1609236552057 implements MigrationInterface diff --git a/server/migration/1610070934506-LocalUsers.ts b/server/migration/1610070934506-LocalUsers.ts index 0ece00f4d..88b0ae607 100644 --- a/server/migration/1610070934506-LocalUsers.ts +++ b/server/migration/1610070934506-LocalUsers.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class LocalUsers1610070934506 implements MigrationInterface { name = 'LocalUsers1610070934506'; diff --git a/server/migration/1610370640747-Add4kStatusFields.ts b/server/migration/1610370640747-Add4kStatusFields.ts index a313bf135..5502b9c0f 100644 --- a/server/migration/1610370640747-Add4kStatusFields.ts +++ b/server/migration/1610370640747-Add4kStatusFields.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class Add4kStatusFields1610370640747 implements MigrationInterface { name = 'Add4kStatusFields1610370640747'; diff --git a/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts b/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts index 25e42a74e..d6574d396 100644 --- a/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts +++ b/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddMediaAddedFieldToMedia1610522845513 implements MigrationInterface diff --git a/server/migration/1611508672722-AddDisplayNameToUser.ts b/server/migration/1611508672722-AddDisplayNameToUser.ts index cacea0597..6a36f29a9 100644 --- a/server/migration/1611508672722-AddDisplayNameToUser.ts +++ b/server/migration/1611508672722-AddDisplayNameToUser.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddDisplayNameToUser1611508672722 implements MigrationInterface { name = 'AddDisplayNameToUser1611508672722'; diff --git a/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts b/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts index 355384a05..5a5b65533 100644 --- a/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts +++ b/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class SonarrRadarrSyncServiceFields1611757511674 implements MigrationInterface diff --git a/server/migration/1611801511397-AddRatingKeysToMedia.ts b/server/migration/1611801511397-AddRatingKeysToMedia.ts index f9865c8f5..92ab4d4b4 100644 --- a/server/migration/1611801511397-AddRatingKeysToMedia.ts +++ b/server/migration/1611801511397-AddRatingKeysToMedia.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddRatingKeysToMedia1611801511397 implements MigrationInterface { name = 'AddRatingKeysToMedia1611801511397'; diff --git a/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts b/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts index 7d191d106..55a20a390 100644 --- a/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts +++ b/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddResetPasswordGuidAndExpiryDate1612482778137 implements MigrationInterface diff --git a/server/migration/1612571545781-AddLanguageProfileId.ts b/server/migration/1612571545781-AddLanguageProfileId.ts index fa89d81b7..7694f4e4f 100644 --- a/server/migration/1612571545781-AddLanguageProfileId.ts +++ b/server/migration/1612571545781-AddLanguageProfileId.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddLanguageProfileId1612571545781 implements MigrationInterface { name = 'AddLanguageProfileId1612571545781'; diff --git a/server/migration/1613379909641-AddJellyfinUserParams.ts b/server/migration/1613379909641-AddJellyfinUserParams.ts index 46ef3319d..b56c873ac 100644 --- a/server/migration/1613379909641-AddJellyfinUserParams.ts +++ b/server/migration/1613379909641-AddJellyfinUserParams.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddJellyfinUserParams1613379909641 implements MigrationInterface { name = 'AddJellyfinUserParams1613379909641'; diff --git a/server/migration/1613412948344-ServerTypeEnum.ts b/server/migration/1613412948344-ServerTypeEnum.ts index b8f950538..0fb18f239 100644 --- a/server/migration/1613412948344-ServerTypeEnum.ts +++ b/server/migration/1613412948344-ServerTypeEnum.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class ServerTypeEnum1613412948344 implements MigrationInterface { name = 'ServerTypeEnum1613412948344'; diff --git a/server/migration/1613615266968-CreateUserSettings.ts b/server/migration/1613615266968-CreateUserSettings.ts index 4d4a973e9..fbe85339c 100644 --- a/server/migration/1613615266968-CreateUserSettings.ts +++ b/server/migration/1613615266968-CreateUserSettings.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class CreateUserSettings1613615266968 implements MigrationInterface { name = 'CreateUserSettings1613615266968'; diff --git a/server/migration/1613670041760-AddJellyfinDeviceId.ts b/server/migration/1613670041760-AddJellyfinDeviceId.ts index 104b4146b..c800f1009 100644 --- a/server/migration/1613670041760-AddJellyfinDeviceId.ts +++ b/server/migration/1613670041760-AddJellyfinDeviceId.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddJellyfinDeviceId1613670041760 implements MigrationInterface { name = 'AddJellyfinDeviceId1613670041760'; diff --git a/server/migration/1613955393450-UpdateUserSettingsRegions.ts b/server/migration/1613955393450-UpdateUserSettingsRegions.ts index d33df4eef..69060a0cb 100644 --- a/server/migration/1613955393450-UpdateUserSettingsRegions.ts +++ b/server/migration/1613955393450-UpdateUserSettingsRegions.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class UpdateUserSettingsRegions1613955393450 implements MigrationInterface diff --git a/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts b/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts index 5e480d481..6e2598ab4 100644 --- a/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts +++ b/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddTelegramSettingsToUserSettings1614334195680 implements MigrationInterface diff --git a/server/migration/1615333940450-AddPGPToUserSettings.ts b/server/migration/1615333940450-AddPGPToUserSettings.ts index b88e0dcaa..6940d4adc 100644 --- a/server/migration/1615333940450-AddPGPToUserSettings.ts +++ b/server/migration/1615333940450-AddPGPToUserSettings.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddPGPToUserSettings1615333940450 implements MigrationInterface { name = 'AddPGPToUserSettings1615333940450'; diff --git a/server/migration/1616576677254-AddUserQuotaFields.ts b/server/migration/1616576677254-AddUserQuotaFields.ts index 632926900..62b39d65a 100644 --- a/server/migration/1616576677254-AddUserQuotaFields.ts +++ b/server/migration/1616576677254-AddUserQuotaFields.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserQuotaFields1616576677254 implements MigrationInterface { name = 'AddUserQuotaFields1616576677254'; diff --git a/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts b/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts index d498a8b17..9e6761825 100644 --- a/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts +++ b/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class CreateTagsFieldonMediaRequest1617624225464 implements MigrationInterface diff --git a/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts b/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts index 79cd061b8..9dd9288e6 100644 --- a/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts +++ b/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserSettingsNotificationAgentsField1617730837489 implements MigrationInterface diff --git a/server/migration/1618912653565-CreateUserPushSubscriptions.ts b/server/migration/1618912653565-CreateUserPushSubscriptions.ts index 539221d17..970705990 100644 --- a/server/migration/1618912653565-CreateUserPushSubscriptions.ts +++ b/server/migration/1618912653565-CreateUserPushSubscriptions.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class CreateUserPushSubscriptions1618912653565 implements MigrationInterface diff --git a/server/migration/1619239659754-AddUserSettingsLocale.ts b/server/migration/1619239659754-AddUserSettingsLocale.ts index 9842bca71..ba182b03a 100644 --- a/server/migration/1619239659754-AddUserSettingsLocale.ts +++ b/server/migration/1619239659754-AddUserSettingsLocale.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserSettingsLocale1619239659754 implements MigrationInterface { name = 'AddUserSettingsLocale1619239659754'; diff --git a/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts b/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts index cccdae2fa..50de959b2 100644 --- a/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts +++ b/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserSettingsNotificationTypes1619339817343 implements MigrationInterface diff --git a/server/migration/1634904083966-AddIssues.ts b/server/migration/1634904083966-AddIssues.ts index 0c6116f9d..ebcf8d89d 100644 --- a/server/migration/1634904083966-AddIssues.ts +++ b/server/migration/1634904083966-AddIssues.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddIssues1634904083966 implements MigrationInterface { name = 'AddIssues1634904083966'; diff --git a/server/migration/1635079863457-AddPushbulletPushoverUserSettings.ts b/server/migration/1635079863457-AddPushbulletPushoverUserSettings.ts index 8934866fa..c29cef6d0 100644 --- a/server/migration/1635079863457-AddPushbulletPushoverUserSettings.ts +++ b/server/migration/1635079863457-AddPushbulletPushoverUserSettings.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddPushbulletPushoverUserSettings1635079863457 implements MigrationInterface diff --git a/server/migration/1660632269368-AddWatchlistSyncUserSetting.ts b/server/migration/1660632269368-AddWatchlistSyncUserSetting.ts new file mode 100644 index 000000000..c0d0e947f --- /dev/null +++ b/server/migration/1660632269368-AddWatchlistSyncUserSetting.ts @@ -0,0 +1,33 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddWatchlistSyncUserSetting1660632269368 + implements MigrationInterface +{ + name = 'AddWatchlistSyncUserSetting1660632269368'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey" FROM "user_settings"` + ); + await queryRunner.query(`DROP TABLE "user_settings"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` + ); + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + } +} diff --git a/server/migration/1660714479373-AddMediaRequestIsAutoRequestedField.ts b/server/migration/1660714479373-AddMediaRequestIsAutoRequestedField.ts new file mode 100644 index 000000000..8580bb4ed --- /dev/null +++ b/server/migration/1660714479373-AddMediaRequestIsAutoRequestedField.ts @@ -0,0 +1,33 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddMediaRequestIsAutoRequestedField1660714479373 + implements MigrationInterface +{ + name = 'AddMediaRequestIsAutoRequestedField1660714479373'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT (0), CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags" FROM "media_request"` + ); + await queryRunner.query(`DROP TABLE "media_request"`); + await queryRunner.query( + `ALTER TABLE "temporary_media_request" RENAME TO "media_request"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "media_request" RENAME TO "temporary_media_request"` + ); + await queryRunner.query( + `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags" FROM "temporary_media_request"` + ); + await queryRunner.query(`DROP TABLE "temporary_media_request"`); + } +} diff --git a/server/models/Collection.ts b/server/models/Collection.ts index 9cc4f3788..20a3c7158 100644 --- a/server/models/Collection.ts +++ b/server/models/Collection.ts @@ -1,8 +1,9 @@ +import type { TmdbCollection } from '@server/api/themoviedb/interfaces'; +import { MediaType } from '@server/constants/media'; +import type Media from '@server/entity/Media'; import { sortBy } from 'lodash'; -import type { TmdbCollection } from '../api/themoviedb/interfaces'; -import { MediaType } from '../constants/media'; -import Media from '../entity/Media'; -import { mapMovieResult, MovieResult } from './Search'; +import type { MovieResult } from './Search'; +import { mapMovieResult } from './Search'; export interface Collection { id: number; diff --git a/server/models/Movie.ts b/server/models/Movie.ts index ac19ce7e0..a216b7437 100644 --- a/server/models/Movie.ts +++ b/server/models/Movie.ts @@ -2,20 +2,22 @@ import type { TmdbMovieDetails, TmdbMovieReleaseResult, TmdbProductionCompany, -} from '../api/themoviedb/interfaces'; -import Media from '../entity/Media'; -import { +} from '@server/api/themoviedb/interfaces'; +import type Media from '@server/entity/Media'; +import type { Cast, Crew, ExternalIds, Genre, + ProductionCompany, + WatchProviders, +} from './common'; +import { mapCast, mapCrew, mapExternalIds, mapVideos, mapWatchProviders, - ProductionCompany, - WatchProviders, } from './common'; export interface Video { diff --git a/server/models/Person.ts b/server/models/Person.ts index 087ab1c7b..998585ee8 100644 --- a/server/models/Person.ts +++ b/server/models/Person.ts @@ -2,8 +2,8 @@ import type { TmdbPersonCreditCast, TmdbPersonCreditCrew, TmdbPersonDetails, -} from '../api/themoviedb/interfaces'; -import Media from '../entity/Media'; +} from '@server/api/themoviedb/interfaces'; +import type Media from '@server/entity/Media'; export interface PersonDetails { id: number; diff --git a/server/models/Search.ts b/server/models/Search.ts index 73427a378..6ab696fe3 100644 --- a/server/models/Search.ts +++ b/server/models/Search.ts @@ -5,9 +5,9 @@ import type { TmdbPersonResult, TmdbTvDetails, TmdbTvResult, -} from '../api/themoviedb/interfaces'; -import { MediaType as MainMediaType } from '../constants/media'; -import Media from '../entity/Media'; +} from '@server/api/themoviedb/interfaces'; +import { MediaType as MainMediaType } from '@server/constants/media'; +import type Media from '@server/entity/Media'; export type MediaType = 'tv' | 'movie' | 'person'; diff --git a/server/models/Tv.ts b/server/models/Tv.ts index b596b1d2b..24362b504 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -5,29 +5,31 @@ 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; name: string; - airDate: string; + airDate: string | null; episodeNumber: number; overview: string; productionCode: string; @@ -48,7 +50,7 @@ interface Season { seasonNumber: number; } -export interface SeasonWithEpisodes extends Season { +export interface SeasonWithEpisodes extends Omit { episodes: Episode[]; externalIds: ExternalIds; } @@ -139,7 +141,6 @@ export const mapSeasonWithEpisodes = ( season: TmdbSeasonWithEpisodes ): SeasonWithEpisodes => ({ airDate: season.air_date, - episodeCount: season.episode_count, episodes: season.episodes.map(mapEpisodeResult), externalIds: mapExternalIds(season.external_ids), id: season.id, diff --git a/server/models/common.ts b/server/models/common.ts index 49e2305cb..30b40d98c 100644 --- a/server/models/common.ts +++ b/server/models/common.ts @@ -7,8 +7,8 @@ import type { TmdbVideoResult, TmdbWatchProviderDetails, TmdbWatchProviders, -} from '../api/themoviedb/interfaces'; -import { Video } from '../models/Movie'; +} from '@server/api/themoviedb/interfaces'; +import type { Video } from '@server/models/Movie'; export interface ProductionCompany { id: number; diff --git a/server/routes/auth.ts b/server/routes/auth.ts index ba8926a3f..35f569fde 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -1,15 +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 { 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(); @@ -83,12 +84,13 @@ authRoutes.post('/plex', async (req, res, next) => { settings.main.mediaServerType = MediaServerType.PLEX; settings.save(); + startJobs(); 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 ?? ''); @@ -261,7 +263,10 @@ authRoutes.post('/jellyfin', async (req, res, next) => { // Update the users avatar with their jellyfin profile pic (incase it changed) if (account.User.PrimaryImageTag) { - user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`; + user.avatar = new URL( + `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`, + jellyfinHost + ).href; } else { user.avatar = '/os_logo_square.png'; } @@ -307,7 +312,10 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinAuthToken: account.AccessToken, permissions: Permission.ADMIN, avatar: account.User.PrimaryImageTag - ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` + ? new URL( + `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`, + jellyfinHost + ).href : '/os_logo_square.png', userType: UserType.JELLYFIN, }); @@ -320,6 +328,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { settings.jellyfin.hostname = body.hostname ?? ''; settings.jellyfin.serverId = account.User.ServerId; settings.save(); + startJobs(); } } @@ -336,7 +345,10 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinAuthToken: account.AccessToken, permissions: settings.main.defaultPermissions, avatar: account.User.PrimaryImageTag - ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` + ? new URL( + `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`, + jellyfinHost + ).href : '/os_logo_square.png', userType: UserType.JELLYFIN, }); @@ -421,8 +433,8 @@ authRoutes.post('/local', async (req, res, next) => { } const mainUser = await userRepository.findOneOrFail({ - select: ['id', 'plexToken', 'plexId'], - order: { id: 'ASC' }, + select: { id: true, plexToken: true, plexId: true }, + where: { id: 1 }, }); const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); diff --git a/server/routes/collection.ts b/server/routes/collection.ts index aa8948736..d58b0357d 100644 --- a/server/routes/collection.ts +++ b/server/routes/collection.ts @@ -1,8 +1,8 @@ +import TheMovieDb from '@server/api/themoviedb'; +import Media from '@server/entity/Media'; +import logger from '@server/logger'; +import { mapCollection } from '@server/models/Collection'; import { Router } from 'express'; -import TheMovieDb from '../api/themoviedb'; -import Media from '../entity/Media'; -import logger from '../logger'; -import { mapCollection } from '../models/Collection'; const collectionRoutes = Router(); diff --git a/server/routes/discover.ts b/server/routes/discover.ts index ea78bf03d..b39a83325 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -1,16 +1,25 @@ +import PlexTvAPI from '@server/api/plextv'; +import TheMovieDb from '@server/api/themoviedb'; +import { MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { User } from '@server/entity/User'; +import type { + GenreSliderItem, + WatchlistResponse, +} from '@server/interfaces/api/discoverInterfaces'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { mapProductionCompany } from '@server/models/Movie'; +import { + mapMovieResult, + mapPersonResult, + mapTvResult, +} from '@server/models/Search'; +import { mapNetwork } from '@server/models/Tv'; +import { isMovie, isPerson } from '@server/utils/typeHelpers'; import { Router } from 'express'; import { sortBy } from 'lodash'; -import TheMovieDb from '../api/themoviedb'; -import { MediaType } from '../constants/media'; -import Media from '../entity/Media'; -import { User } from '../entity/User'; -import { GenreSliderItem } from '../interfaces/api/discoverInterfaces'; -import { getSettings } from '../lib/settings'; -import logger from '../logger'; -import { mapProductionCompany } from '../models/Movie'; -import { mapMovieResult, mapPersonResult, mapTvResult } from '../models/Search'; -import { mapNetwork } from '../models/Tv'; -import { isMovie, isPerson } from '../utils/typeHelpers'; export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => { const settings = getSettings(); @@ -704,4 +713,45 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>( } ); +discoverRoutes.get<{ page?: number }, WatchlistResponse>( + '/watchlist', + async (req, res) => { + const userRepository = getRepository(User); + const itemsPerPage = 20; + const page = req.params.page ?? 1; + const offset = (page - 1) * itemsPerPage; + + const activeUser = await userRepository.findOne({ + where: { id: req.user?.id }, + select: ['id', 'plexToken'], + }); + + if (!activeUser?.plexToken) { + // We will just return an empty array if the user has no Plex token + return res.json({ + page: 1, + totalPages: 1, + totalResults: 0, + results: [], + }); + } + + const plexTV = new PlexTvAPI(activeUser.plexToken); + + const watchlist = await plexTV.getWatchlist({ offset }); + + return res.json({ + page, + totalPages: Math.ceil(watchlist.size / itemsPerPage), + totalResults: watchlist.size, + results: watchlist.items.map((item) => ({ + ratingKey: item.ratingKey, + title: item.title, + mediaType: item.type === 'show' ? 'tv' : 'movie', + tmdbId: item.tmdbId, + })), + }); + } +); + export default discoverRoutes; diff --git a/server/routes/index.ts b/server/routes/index.ts index e28666385..9561e171b 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -1,17 +1,22 @@ +import GithubAPI from '@server/api/github'; +import TheMovieDb from '@server/api/themoviedb'; +import type { + TmdbMovieResult, + TmdbTvResult, +} from '@server/api/themoviedb/interfaces'; +import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces'; +import { Permission } from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { checkUser, isAuthenticated } from '@server/middleware/auth'; +import { mapProductionCompany } from '@server/models/Movie'; +import { mapNetwork } from '@server/models/Tv'; +import settingsRoutes from '@server/routes/settings'; +import { appDataPath, appDataStatus } from '@server/utils/appDataVolume'; +import { getAppVersion, getCommitTag } from '@server/utils/appVersion'; +import restartFlag from '@server/utils/restartFlag'; +import { isPerson } from '@server/utils/typeHelpers'; import { Router } from 'express'; -import GithubAPI from '../api/github'; -import TheMovieDb from '../api/themoviedb'; -import { TmdbMovieResult, TmdbTvResult } from '../api/themoviedb/interfaces'; -import { StatusResponse } from '../interfaces/api/settingsInterfaces'; -import { Permission } from '../lib/permissions'; -import { getSettings } from '../lib/settings'; -import logger from '../logger'; -import { checkUser, isAuthenticated } from '../middleware/auth'; -import { mapProductionCompany } from '../models/Movie'; -import { mapNetwork } from '../models/Tv'; -import { appDataPath, appDataStatus } from '../utils/appDataVolume'; -import { getAppVersion, getCommitTag } from '../utils/appVersion'; -import { isPerson } from '../utils/typeHelpers'; import authRoutes from './auth'; import collectionRoutes from './collection'; import discoverRoutes, { createTmdbWithRegionLanguage } from './discover'; @@ -23,7 +28,6 @@ import personRoutes from './person'; import requestRoutes from './request'; import searchRoutes from './search'; import serviceRoutes from './service'; -import settingsRoutes from './settings'; import tvRoutes from './tv'; import user from './user'; @@ -75,6 +79,7 @@ router.get('/status', async (req, res) => { commitTag: getCommitTag(), updateAvailable, commitsBehind, + restartRequired: restartFlag.isSet(), }); }); @@ -97,11 +102,7 @@ router.get('/settings/public', async (req, res) => { return res.status(200).json(settings.fullPublicSettings); } }); -router.use( - '/settings', - isAuthenticated(Permission.MANAGE_SETTINGS), - settingsRoutes -); +router.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes); router.use('/search', isAuthenticated(), searchRoutes); router.use('/discover', isAuthenticated(), discoverRoutes); router.use('/request', isAuthenticated(), requestRoutes); diff --git a/server/routes/issue.ts b/server/routes/issue.ts index 07cf3277d..6349bb74a 100644 --- a/server/routes/issue.ts +++ b/server/routes/issue.ts @@ -1,13 +1,13 @@ +import { IssueStatus, IssueType } from '@server/constants/issue'; +import { getRepository } from '@server/datasource'; +import Issue from '@server/entity/Issue'; +import IssueComment from '@server/entity/IssueComment'; +import Media from '@server/entity/Media'; +import type { IssueResultsResponse } from '@server/interfaces/api/issueInterfaces'; +import { Permission } from '@server/lib/permissions'; +import logger from '@server/logger'; +import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; -import { getRepository } from 'typeorm'; -import { IssueStatus, IssueType } from '../constants/issue'; -import Issue from '../entity/Issue'; -import IssueComment from '../entity/IssueComment'; -import Media from '../entity/Media'; -import { IssueResultsResponse } from '../interfaces/api/issueInterfaces'; -import { Permission } from '../lib/permissions'; -import logger from '../logger'; -import { isAuthenticated } from '../middleware/auth'; const issueRoutes = Router(); @@ -365,7 +365,7 @@ issueRoutes.delete( try { const issue = await issueRepository.findOneOrFail({ where: { id: Number(req.params.issueId) }, - relations: ['createdBy'], + relations: { createdBy: true }, }); if ( diff --git a/server/routes/issueComment.ts b/server/routes/issueComment.ts index c54bce5b6..85e41aaaf 100644 --- a/server/routes/issueComment.ts +++ b/server/routes/issueComment.ts @@ -1,9 +1,9 @@ +import { getRepository } from '@server/datasource'; +import IssueComment from '@server/entity/IssueComment'; +import { Permission } from '@server/lib/permissions'; +import logger from '@server/logger'; +import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; -import { getRepository } from 'typeorm'; -import IssueComment from '../entity/IssueComment'; -import { Permission } from '../lib/permissions'; -import logger from '../logger'; -import { isAuthenticated } from '../middleware/auth'; const issueCommentRoutes = Router(); diff --git a/server/routes/media.ts b/server/routes/media.ts index 429b2010f..8f93116c0 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -1,17 +1,19 @@ -import { Router } from 'express'; -import { FindOneOptions, FindOperator, getRepository, In } from 'typeorm'; -import TautulliAPI from '../api/tautulli'; -import { MediaStatus, MediaType } from '../constants/media'; -import Media from '../entity/Media'; -import { User } from '../entity/User'; -import { +import TautulliAPI from '@server/api/tautulli'; +import { MediaStatus, MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { User } from '@server/entity/User'; +import type { MediaResultsResponse, MediaWatchDataResponse, -} from '../interfaces/api/mediaInterfaces'; -import { Permission } from '../lib/permissions'; -import { getSettings } from '../lib/settings'; -import logger from '../logger'; -import { isAuthenticated } from '../middleware/auth'; +} from '@server/interfaces/api/mediaInterfaces'; +import { Permission } from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { isAuthenticated } from '@server/middleware/auth'; +import { Router } from 'express'; +import type { FindOneOptions } from 'typeorm'; +import { In } from 'typeorm'; const mediaRoutes = Router(); @@ -21,8 +23,7 @@ mediaRoutes.get('/', async (req, res, next) => { const pageSize = req.query.take ? Number(req.query.take) : 20; const skip = req.query.skip ? Number(req.query.skip) : 0; - let statusFilter: MediaStatus | FindOperator | undefined = - undefined; + let statusFilter = undefined; switch (req.query.filter) { case 'available': @@ -66,7 +67,7 @@ mediaRoutes.get('/', async (req, res, next) => { try { const [media, mediaCount] = await mediaRepository.findAndCount({ order: sortFilter, - where: { + where: statusFilter && { status: statusFilter, }, take: pageSize, @@ -151,7 +152,7 @@ mediaRoutes.delete( const mediaRepository = getRepository(Media); const media = await mediaRepository.findOneOrFail({ - where: { id: req.params.id }, + where: { id: Number(req.params.id) }, }); await mediaRepository.remove(media); diff --git a/server/routes/movie.ts b/server/routes/movie.ts index 98474c78e..f11cead8c 100644 --- a/server/routes/movie.ts +++ b/server/routes/movie.ts @@ -1,11 +1,11 @@ +import RottenTomatoes from '@server/api/rottentomatoes'; +import TheMovieDb from '@server/api/themoviedb'; +import { MediaType } from '@server/constants/media'; +import Media from '@server/entity/Media'; +import logger from '@server/logger'; +import { mapMovieDetails } from '@server/models/Movie'; +import { mapMovieResult } from '@server/models/Search'; import { Router } from 'express'; -import RottenTomatoes from '../api/rottentomatoes'; -import TheMovieDb from '../api/themoviedb'; -import { MediaType } from '../constants/media'; -import Media from '../entity/Media'; -import logger from '../logger'; -import { mapMovieDetails } from '../models/Movie'; -import { mapMovieResult } from '../models/Search'; const movieRoutes = Router(); diff --git a/server/routes/person.ts b/server/routes/person.ts index 5093ae46c..009d62af7 100644 --- a/server/routes/person.ts +++ b/server/routes/person.ts @@ -1,12 +1,14 @@ -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 { MediaStatus } from '@server/constants/media'; +import Media from '@server/entity/Media'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; import { mapCastCredits, mapCrewCredits, mapPersonDetails, -} from '../models/Person'; +} from '@server/models/Person'; +import { Router } from 'express'; const personRoutes = Router(); @@ -34,6 +36,7 @@ personRoutes.get('/:id', async (req, res, next) => { personRoutes.get('/:id/combined_credits', async (req, res, next) => { const tmdb = new TheMovieDb(); + const settings = getSettings(); try { const combinedCredits = await tmdb.getPersonCombinedCredits({ @@ -41,14 +44,30 @@ personRoutes.get('/:id/combined_credits', async (req, res, next) => { language: req.locale ?? (req.query.language as string), }); - const castMedia = await Media.getRelatedMedia( + let castMedia = await Media.getRelatedMedia( combinedCredits.cast.map((result) => result.id) ); - const crewMedia = await Media.getRelatedMedia( + let crewMedia = await Media.getRelatedMedia( combinedCredits.crew.map((result) => result.id) ); + if (settings.main.hideAvailable) { + castMedia = castMedia.filter( + (media) => + (media.mediaType === 'movie' || media.mediaType === 'tv') && + media.status !== MediaStatus.AVAILABLE && + media.status !== MediaStatus.PARTIALLY_AVAILABLE + ); + + crewMedia = crewMedia.filter( + (media) => + (media.mediaType === 'movie' || media.mediaType === 'tv') && + media.status !== MediaStatus.AVAILABLE && + media.status !== MediaStatus.PARTIALLY_AVAILABLE + ); + } + return res.status(200).json({ cast: combinedCredits.cast .map((result) => diff --git a/server/routes/request.ts b/server/routes/request.ts index cd269f4ef..9c9d96a82 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -1,15 +1,27 @@ +import { + MediaRequestStatus, + MediaStatus, + MediaType, +} from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { + DuplicateMediaRequestError, + MediaRequest, + NoSeasonsAvailableError, + QuotaRestrictedError, + RequestPermissionError, +} from '@server/entity/MediaRequest'; +import SeasonRequest from '@server/entity/SeasonRequest'; +import { User } from '@server/entity/User'; +import type { + MediaRequestBody, + RequestResultsResponse, +} from '@server/interfaces/api/requestInterfaces'; +import { Permission } from '@server/lib/permissions'; +import logger from '@server/logger'; +import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; -import { getRepository } from 'typeorm'; -import TheMovieDb from '../api/themoviedb'; -import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media'; -import Media from '../entity/Media'; -import { MediaRequest } from '../entity/MediaRequest'; -import SeasonRequest from '../entity/SeasonRequest'; -import { User } from '../entity/User'; -import { RequestResultsResponse } from '../interfaces/api/requestInterfaces'; -import { Permission } from '../lib/permissions'; -import logger from '../logger'; -import { isAuthenticated } from '../middleware/auth'; const requestRoutes = Router(); @@ -40,11 +52,15 @@ requestRoutes.get, RequestResultsResponse>( MediaRequestStatus.APPROVED, ]; break; + case 'failed': + statusFilter = [MediaRequestStatus.FAILED]; + break; default: statusFilter = [ MediaRequestStatus.PENDING, MediaRequestStatus.APPROVED, MediaRequestStatus.DECLINED, + MediaRequestStatus.FAILED, ]; } @@ -142,302 +158,38 @@ requestRoutes.get, RequestResultsResponse>( } ); -requestRoutes.post('/', async (req, res, next) => { - const tmdb = new TheMovieDb(); - const mediaRepository = getRepository(Media); - const requestRepository = getRepository(MediaRequest); - const userRepository = getRepository(User); - - try { - let requestUser = req.user; - - if ( - req.body.userId && - !req.user?.hasPermission([ - Permission.MANAGE_USERS, - Permission.MANAGE_REQUESTS, - ]) - ) { - return next({ - status: 403, - message: 'You do not have permission to modify the request user.', - }); - } else if (req.body.userId) { - requestUser = await userRepository.findOneOrFail({ - where: { id: req.body.userId }, - }); - } - - if (!requestUser) { - return next({ - status: 500, - message: 'User missing from request context.', - }); - } - - if ( - req.body.mediaType === MediaType.MOVIE && - !req.user?.hasPermission( - req.body.is4k - ? [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE] - : [Permission.REQUEST, Permission.REQUEST_MOVIE], - { - type: 'or', - } - ) - ) { - return next({ - status: 403, - message: `You do not have permission to make ${ - req.body.is4k ? '4K ' : '' - }movie requests.`, - }); - } else if ( - req.body.mediaType === MediaType.TV && - !req.user?.hasPermission( - req.body.is4k - ? [Permission.REQUEST_4K, Permission.REQUEST_4K_TV] - : [Permission.REQUEST, Permission.REQUEST_TV], - { - type: 'or', - } - ) - ) { - return next({ - status: 403, - message: `You do not have permission to make ${ - req.body.is4k ? '4K ' : '' - }series requests.`, - }); - } - - const quotas = await requestUser.getQuota(); - - if (req.body.mediaType === MediaType.MOVIE && quotas.movie.restricted) { - return next({ - status: 403, - message: 'Movie Quota Exceeded', - }); - } else if (req.body.mediaType === MediaType.TV && quotas.tv.restricted) { - return next({ - status: 403, - message: 'Series Quota Exceeded', - }); - } - - const tmdbMedia = - req.body.mediaType === MediaType.MOVIE - ? await tmdb.getMovie({ movieId: req.body.mediaId }) - : await tmdb.getTvShow({ tvId: req.body.mediaId }); - - let media = await mediaRepository.findOne({ - where: { tmdbId: req.body.mediaId, mediaType: req.body.mediaType }, - relations: ['requests'], - }); - - if (!media) { - media = new Media({ - tmdbId: tmdbMedia.id, - tvdbId: req.body.tvdbId ?? tmdbMedia.external_ids.tvdb_id, - status: !req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, - status4k: req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, - mediaType: req.body.mediaType, - }); - } else { - if (media.status === MediaStatus.UNKNOWN && !req.body.is4k) { - media.status = MediaStatus.PENDING; - } - - if (media.status4k === MediaStatus.UNKNOWN && req.body.is4k) { - media.status4k = MediaStatus.PENDING; - } - } - - if (req.body.mediaType === MediaType.MOVIE) { - const existing = await requestRepository - .createQueryBuilder('request') - .leftJoin('request.media', 'media') - .where('request.is4k = :is4k', { is4k: req.body.is4k }) - .andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id }) - .andWhere('media.mediaType = :mediaType', { - mediaType: MediaType.MOVIE, - }) - .andWhere('request.status != :requestStatus', { - requestStatus: MediaRequestStatus.DECLINED, - }) - .getOne(); - - if (existing) { - logger.warn('Duplicate request for media blocked', { - tmdbId: tmdbMedia.id, - mediaType: req.body.mediaType, - is4k: req.body.is4k, - label: 'Media Request', - }); +requestRoutes.post( + '/', + async (req, res, next) => { + try { + if (!req.user) { return next({ - status: 409, - message: 'Request for this media already exists.', + status: 401, + message: 'You must be logged in to request media.', }); } + const request = await MediaRequest.request(req.body, req.user); - await mediaRepository.save(media); - - const request = new MediaRequest({ - type: MediaType.MOVIE, - media, - requestedBy: requestUser, - // If the user is an admin or has the "auto approve" permission, automatically approve the request - status: req.user?.hasPermission( - [ - req.body.is4k - ? Permission.AUTO_APPROVE_4K - : Permission.AUTO_APPROVE, - req.body.is4k - ? Permission.AUTO_APPROVE_4K_MOVIE - : Permission.AUTO_APPROVE_MOVIE, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } - ) - ? MediaRequestStatus.APPROVED - : MediaRequestStatus.PENDING, - modifiedBy: req.user?.hasPermission( - [ - req.body.is4k - ? Permission.AUTO_APPROVE_4K - : Permission.AUTO_APPROVE, - req.body.is4k - ? Permission.AUTO_APPROVE_4K_MOVIE - : Permission.AUTO_APPROVE_MOVIE, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } - ) - ? req.user - : undefined, - is4k: req.body.is4k, - serverId: req.body.serverId, - profileId: req.body.profileId, - rootFolder: req.body.rootFolder, - tags: req.body.tags, - }); - - await requestRepository.save(request); return res.status(201).json(request); - } else if (req.body.mediaType === MediaType.TV) { - const requestedSeasons = req.body.seasons as number[]; - let existingSeasons: number[] = []; - - // We need to check existing requests on this title to make sure we don't double up on seasons that were - // already requested. In the case they were, we just throw out any duplicates but still approve the request. - // (Unless there are no seasons, in which case we abort) - if (media.requests) { - existingSeasons = media.requests - .filter( - (request) => - request.is4k === req.body.is4k && - request.status !== MediaRequestStatus.DECLINED - ) - .reduce((seasons, request) => { - const combinedSeasons = request.seasons.map( - (season) => season.seasonNumber - ); - - return [...seasons, ...combinedSeasons]; - }, [] as number[]); + } catch (error) { + if (!(error instanceof Error)) { + return; } - const finalSeasons = requestedSeasons.filter( - (rs) => !existingSeasons.includes(rs) - ); - - if (finalSeasons.length === 0) { - return next({ - status: 202, - message: 'No seasons available to request', - }); - } else if ( - quotas.tv.limit && - finalSeasons.length > (quotas.tv.remaining ?? 0) - ) { - return next({ - status: 403, - message: 'Series Quota Exceeded', - }); + switch (error.constructor) { + case RequestPermissionError: + case QuotaRestrictedError: + return next({ status: 403, message: error.message }); + case DuplicateMediaRequestError: + return next({ status: 409, message: error.message }); + case NoSeasonsAvailableError: + return next({ status: 202, message: error.message }); + default: + return next({ status: 500, message: error.message }); } - - await mediaRepository.save(media); - - const request = new MediaRequest({ - type: MediaType.TV, - media, - requestedBy: requestUser, - // If the user is an admin or has the "auto approve" permission, automatically approve the request - status: req.user?.hasPermission( - [ - req.body.is4k - ? Permission.AUTO_APPROVE_4K - : Permission.AUTO_APPROVE, - req.body.is4k - ? Permission.AUTO_APPROVE_4K_TV - : Permission.AUTO_APPROVE_TV, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } - ) - ? MediaRequestStatus.APPROVED - : MediaRequestStatus.PENDING, - modifiedBy: req.user?.hasPermission( - [ - req.body.is4k - ? Permission.AUTO_APPROVE_4K - : Permission.AUTO_APPROVE, - req.body.is4k - ? Permission.AUTO_APPROVE_4K_TV - : Permission.AUTO_APPROVE_TV, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } - ) - ? req.user - : undefined, - is4k: req.body.is4k, - serverId: req.body.serverId, - profileId: req.body.profileId, - rootFolder: req.body.rootFolder, - languageProfileId: req.body.languageProfileId, - tags: req.body.tags, - seasons: finalSeasons.map( - (sn) => - new SeasonRequest({ - seasonNumber: sn, - status: req.user?.hasPermission( - [ - req.body.is4k - ? Permission.AUTO_APPROVE_4K - : Permission.AUTO_APPROVE, - req.body.is4k - ? Permission.AUTO_APPROVE_4K_TV - : Permission.AUTO_APPROVE_TV, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } - ) - ? MediaRequestStatus.APPROVED - : MediaRequestStatus.PENDING, - }) - ), - }); - - await requestRepository.save(request); - return res.status(201).json(request); } - - next({ status: 500, message: 'Invalid media type' }); - } catch (e) { - next({ status: 500, message: e.message }); } -}); +); requestRoutes.get('/count', async (_req, res, next) => { const requestRepository = getRepository(MediaRequest); @@ -528,7 +280,7 @@ requestRoutes.get('/:requestId', async (req, res, next) => { try { const request = await requestRepository.findOneOrFail({ where: { id: Number(req.params.requestId) }, - relations: ['requestedBy', 'modifiedBy'], + relations: { requestedBy: true, modifiedBy: true }, }); if ( @@ -560,9 +312,11 @@ requestRoutes.put<{ requestId: string }>( const requestRepository = getRepository(MediaRequest); const userRepository = getRepository(User); try { - const request = await requestRepository.findOne( - Number(req.params.requestId) - ); + const request = await requestRepository.findOne({ + where: { + id: Number(req.params.requestId), + }, + }); if (!request) { return next({ status: 404, message: 'Request not found.' }); @@ -628,7 +382,7 @@ requestRoutes.put<{ requestId: string }>( // Get existing media so we can work with all the requests const media = await mediaRepository.findOneOrFail({ where: { tmdbId: request.media.tmdbId, mediaType: MediaType.TV }, - relations: ['requests'], + relations: { requests: true }, }); // Get all requested seasons that are not part of this request we are editing @@ -698,7 +452,7 @@ requestRoutes.delete('/:requestId', async (req, res, next) => { try { const request = await requestRepository.findOneOrFail({ where: { id: Number(req.params.requestId) }, - relations: ['requestedBy', 'modifiedBy'], + relations: { requestedBy: true, modifiedBy: true }, }); if ( @@ -735,7 +489,7 @@ requestRoutes.post<{ try { const request = await requestRepository.findOneOrFail({ where: { id: Number(req.params.requestId) }, - relations: ['requestedBy', 'modifiedBy'], + relations: { requestedBy: true, modifiedBy: true }, }); await request.updateParentStatus(); @@ -763,7 +517,7 @@ requestRoutes.post<{ try { const request = await requestRepository.findOneOrFail({ where: { id: Number(req.params.requestId) }, - relations: ['requestedBy', 'modifiedBy'], + relations: { requestedBy: true, modifiedBy: true }, }); let newStatus: MediaRequestStatus; diff --git a/server/routes/search.ts b/server/routes/search.ts index 3f26a3939..1152bce31 100644 --- a/server/routes/search.ts +++ b/server/routes/search.ts @@ -1,10 +1,10 @@ +import TheMovieDb from '@server/api/themoviedb'; +import type { TmdbSearchMultiResponse } from '@server/api/themoviedb/interfaces'; +import Media from '@server/entity/Media'; +import { findSearchProvider } from '@server/lib/search'; +import logger from '@server/logger'; +import { mapSearchResults } from '@server/models/Search'; import { Router } from 'express'; -import TheMovieDb from '../api/themoviedb'; -import { TmdbSearchMultiResponse } from '../api/themoviedb/interfaces'; -import Media from '../entity/Media'; -import { findSearchProvider } from '../lib/search'; -import logger from '../logger'; -import { mapSearchResults } from '../models/Search'; const searchRoutes = Router(); diff --git a/server/routes/service.ts b/server/routes/service.ts index 862ab3748..b77d58c9d 100644 --- a/server/routes/service.ts +++ b/server/routes/service.ts @@ -1,13 +1,13 @@ -import { Router } from 'express'; -import RadarrAPI from '../api/servarr/radarr'; -import SonarrAPI from '../api/servarr/sonarr'; -import TheMovieDb from '../api/themoviedb'; -import { +import RadarrAPI from '@server/api/servarr/radarr'; +import SonarrAPI from '@server/api/servarr/sonarr'; +import TheMovieDb from '@server/api/themoviedb'; +import type { ServiceCommonServer, ServiceCommonServerWithDetails, -} from '../interfaces/api/serviceInterfaces'; -import { getSettings } from '../lib/settings'; -import logger from '../logger'; +} from '@server/interfaces/api/serviceInterfaces'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { Router } from 'express'; const serviceRoutes = Router(); @@ -191,7 +191,7 @@ serviceRoutes.get<{ tmdbId: string }>( try { const tv = await tmdb.getTvShow({ tvId: Number(req.params.tmdbId), - language: req.locale ?? (req.query.language as string), + language: 'en', }); const response = await sonarr.getSeriesByTitle(tv.name); diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 7ebff7605..2d9fc2ffa 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -1,35 +1,37 @@ +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'; -import { merge, omit, set, sortBy } from 'lodash'; +import { escapeRegExp, 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( @@ -326,7 +330,10 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => { username: user.Name, id: user.Id, thumb: user.PrimaryImageTag - ? `${jellyfinHost}/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90` + ? new URL( + `/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90`, + jellyfinHost + ).href : '/os_logo_square.png', email: user.Name, })); @@ -390,8 +397,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( @@ -450,6 +457,8 @@ settingsRoutes.get( (req, res, next) => { const pageSize = req.query.take ? Number(req.query.take) : 25; const skip = req.query.skip ? Number(req.query.skip) : 0; + const search = (req.query.search as string) ?? ''; + const searchRegexp = new RegExp(escapeRegExp(search), 'i'); let filter: string[] = []; switch (req.query.filter) { @@ -481,6 +490,22 @@ settingsRoutes.get( 'data', ]; + const deepValueStrings = (obj: Record): string[] => { + const values = []; + + for (const val of Object.values(obj)) { + if (typeof val === 'string') { + values.push(val); + } else if (typeof val === 'number') { + values.push(val.toString()); + } else if (val !== null && typeof val === 'object') { + values.push(...deepValueStrings(val as Record)); + } + } + + return values; + }; + try { fs.readFileSync(logFile, 'utf-8') .split('\n') @@ -505,6 +530,19 @@ settingsRoutes.get( }); } + if (req.query.search) { + if ( + // label and data are sometimes undefined + !searchRegexp.test(logMessage.label ?? '') && + !searchRegexp.test(logMessage.message) && + !deepValueStrings(logMessage.data ?? {}).some((val) => + searchRegexp.test(val) + ) + ) { + return; + } + } + logs.push(logMessage); }); @@ -539,6 +577,7 @@ settingsRoutes.get('/jobs', (_req, res) => { name: job.name, type: job.type, interval: job.interval, + cronSchedule: job.cronSchedule, nextExecutionTime: job.job.nextInvocation(), running: job.running ? job.running() : false, })) @@ -559,6 +598,7 @@ settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => { name: scheduledJob.name, type: scheduledJob.type, interval: scheduledJob.interval, + cronSchedule: scheduledJob.cronSchedule, nextExecutionTime: scheduledJob.job.nextInvocation(), running: scheduledJob.running ? scheduledJob.running() : false, }); @@ -584,6 +624,7 @@ settingsRoutes.post<{ jobId: string }>( name: scheduledJob.name, type: scheduledJob.type, interval: scheduledJob.interval, + cronSchedule: scheduledJob.cronSchedule, nextExecutionTime: scheduledJob.job.nextInvocation(), running: scheduledJob.running ? scheduledJob.running() : false, }); @@ -608,11 +649,14 @@ settingsRoutes.post<{ jobId: string }>( settings.jobs[scheduledJob.id].schedule = req.body.schedule; settings.save(); + scheduledJob.cronSchedule = req.body.schedule; + return res.status(200).json({ id: scheduledJob.id, name: scheduledJob.name, type: scheduledJob.type, interval: scheduledJob.interval, + cronSchedule: scheduledJob.cronSchedule, nextExecutionTime: scheduledJob.job.nextInvocation(), running: scheduledJob.running ? scheduledJob.running() : false, }); diff --git a/server/routes/settings/notifications.ts b/server/routes/settings/notifications.ts index 5a337237d..5a38555ca 100644 --- a/server/routes/settings/notifications.ts +++ b/server/routes/settings/notifications.ts @@ -1,23 +1,24 @@ +import type { User } from '@server/entity/User'; +import { Notification } from '@server/lib/notifications'; +import type { NotificationAgent } from '@server/lib/notifications/agents/agent'; +import DiscordAgent from '@server/lib/notifications/agents/discord'; +import EmailAgent from '@server/lib/notifications/agents/email'; +import GotifyAgent from '@server/lib/notifications/agents/gotify'; +import LunaSeaAgent from '@server/lib/notifications/agents/lunasea'; +import PushbulletAgent from '@server/lib/notifications/agents/pushbullet'; +import PushoverAgent from '@server/lib/notifications/agents/pushover'; +import SlackAgent from '@server/lib/notifications/agents/slack'; +import TelegramAgent from '@server/lib/notifications/agents/telegram'; +import WebhookAgent from '@server/lib/notifications/agents/webhook'; +import WebPushAgent from '@server/lib/notifications/agents/webpush'; +import { getSettings } from '@server/lib/settings'; import { Router } from 'express'; -import { User } from '../../entity/User'; -import { Notification } from '../../lib/notifications'; -import { NotificationAgent } from '../../lib/notifications/agents/agent'; -import DiscordAgent from '../../lib/notifications/agents/discord'; -import EmailAgent from '../../lib/notifications/agents/email'; -import GotifyAgent from '../../lib/notifications/agents/gotify'; -import LunaSeaAgent from '../../lib/notifications/agents/lunasea'; -import PushbulletAgent from '../../lib/notifications/agents/pushbullet'; -import PushoverAgent from '../../lib/notifications/agents/pushover'; -import SlackAgent from '../../lib/notifications/agents/slack'; -import TelegramAgent from '../../lib/notifications/agents/telegram'; -import WebhookAgent from '../../lib/notifications/agents/webhook'; -import WebPushAgent from '../../lib/notifications/agents/webpush'; -import { getSettings } from '../../lib/settings'; const notificationRoutes = Router(); const sendTestNotification = async (agent: NotificationAgent, user: User) => await agent.send(Notification.TEST_NOTIFICATION, { + notifySystem: true, notifyAdmin: false, notifyUser: user, subject: 'Test Notification', @@ -247,7 +248,7 @@ notificationRoutes.post('/webpush/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, - message: 'User information missing from request', + message: 'User information is missing from the request.', }); } @@ -363,7 +364,7 @@ notificationRoutes.post('/lunasea/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, - message: 'User information missing from request', + message: 'User information is missing from the request.', }); } @@ -384,34 +385,26 @@ notificationRoutes.get('/gotify', (_req, res) => { res.status(200).json(settings.notifications.agents.gotify); }); -notificationRoutes.post('/gotify', (req, rest) => { +notificationRoutes.post('/gotify', (req, res) => { const settings = getSettings(); settings.notifications.agents.gotify = req.body; settings.save(); - rest.status(200).json(settings.notifications.agents.gotify); + res.status(200).json(settings.notifications.agents.gotify); }); -notificationRoutes.post('/gotify/test', async (req, rest, next) => { +notificationRoutes.post('/gotify/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, - message: 'User information is missing from request', + message: 'User information is missing from the request.', }); } const gotifyAgent = new GotifyAgent(req.body); - if ( - await gotifyAgent.send(Notification.TEST_NOTIFICATION, { - notifyAdmin: false, - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }) - ) { - return rest.status(204).send(); + if (await sendTestNotification(gotifyAgent, req.user)) { + return res.status(204).send(); } else { return next({ status: 500, diff --git a/server/routes/settings/radarr.ts b/server/routes/settings/radarr.ts index a33bfcdba..c2b0a6f52 100644 --- a/server/routes/settings/radarr.ts +++ b/server/routes/settings/radarr.ts @@ -1,7 +1,8 @@ +import RadarrAPI from '@server/api/servarr/radarr'; +import type { RadarrSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; import { Router } from 'express'; -import RadarrAPI from '../../api/servarr/radarr'; -import { getSettings, RadarrSettings } from '../../lib/settings'; -import logger from '../../logger'; const radarrRoutes = Router(); diff --git a/server/routes/settings/sonarr.ts b/server/routes/settings/sonarr.ts index da5a5bb3f..358d07002 100644 --- a/server/routes/settings/sonarr.ts +++ b/server/routes/settings/sonarr.ts @@ -1,7 +1,8 @@ +import SonarrAPI from '@server/api/servarr/sonarr'; +import type { SonarrSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; import { Router } from 'express'; -import SonarrAPI from '../../api/servarr/sonarr'; -import { getSettings, SonarrSettings } from '../../lib/settings'; -import logger from '../../logger'; const sonarrRoutes = Router(); diff --git a/server/routes/tv.ts b/server/routes/tv.ts index 201e7afe3..d45e40620 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -1,11 +1,11 @@ +import RottenTomatoes from '@server/api/rottentomatoes'; +import TheMovieDb from '@server/api/themoviedb'; +import { MediaType } from '@server/constants/media'; +import Media from '@server/entity/Media'; +import logger from '@server/logger'; +import { mapTvResult } from '@server/models/Search'; +import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv'; import { Router } from 'express'; -import RottenTomatoes from '../api/rottentomatoes'; -import TheMovieDb from '../api/themoviedb'; -import { MediaType } from '../constants/media'; -import Media from '../entity/Media'; -import logger from '../logger'; -import { mapTvResult } from '../models/Search'; -import { mapSeasonWithEpisodes, mapTvDetails } from '../models/Tv'; const tvRoutes = Router(); diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 5811fc05f..258a3eae0 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', @@ -523,7 +525,10 @@ router.post( email: jellyfinUser?.Name, permissions: settings.main.defaultPermissions, avatar: jellyfinUser?.PrimaryImageTag - ? `${jellyfinHost}/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90` + ? new URL( + `/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90`, + jellyfinHost + ).href : '/os_logo_square.png', userType: UserType.JELLYFIN, }); @@ -598,7 +603,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 +685,60 @@ router.get<{ id: string }, UserWatchDataResponse>( } ); +router.get<{ id: string; page?: number }, WatchlistResponse>( + '/:id/watchlist', + async (req, res, next) => { + if ( + Number(req.params.id) !== req.user?.id && + !req.user?.hasPermission( + [Permission.MANAGE_REQUESTS, Permission.WATCHLIST_VIEW], + { + type: 'or', + } + ) + ) { + return next({ + status: 403, + message: + "You do not have permission to view this user's Plex Watchlist.", + }); + } + + const itemsPerPage = 20; + const page = req.params.page ?? 1; + const offset = (page - 1) * itemsPerPage; + + const user = await getRepository(User).findOneOrFail({ + where: { id: Number(req.params.id) }, + select: { id: true, plexToken: true }, + }); + + if (!user?.plexToken) { + // We will just return an empty array if the user has no Plex token + return res.json({ + page: 1, + totalPages: 1, + totalResults: 0, + results: [], + }); + } + + const plexTV = new PlexTvAPI(user.plexToken); + + const watchlist = await plexTV.getWatchlist({ offset }); + + return res.json({ + page, + totalPages: Math.ceil(watchlist.size / itemsPerPage), + totalResults: watchlist.size, + results: watchlist.items.map((item) => ({ + ratingKey: item.ratingKey, + title: item.title, + mediaType: item.type === 'show' ? 'tv' : 'movie', + tmdbId: item.tmdbId, + })), + }); + } +); + export default router; diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index a05311a22..9b9a11ece 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -1,16 +1,16 @@ -import { Router } from 'express'; -import { getRepository } from 'typeorm'; -import { canMakePermissionsChange } from '.'; -import { User } from '../../entity/User'; -import { UserSettings } from '../../entity/UserSettings'; -import { +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import { UserSettings } from '@server/entity/UserSettings'; +import type { UserSettingsGeneralResponse, UserSettingsNotificationsResponse, -} from '../../interfaces/api/userSettingsInterfaces'; -import { Permission } from '../../lib/permissions'; -import { getSettings } from '../../lib/settings'; -import logger from '../../logger'; -import { isAuthenticated } from '../../middleware/auth'; +} from '@server/interfaces/api/userSettingsInterfaces'; +import { Permission } from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { isAuthenticated } from '@server/middleware/auth'; +import { Router } from 'express'; +import { canMakePermissionsChange } from '.'; const isOwnProfileOrAdmin = (): Middleware => { const authMiddleware: Middleware = (req, res, next) => { @@ -64,6 +64,8 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>( globalMovieQuotaLimit: defaultQuotas.movie.quotaLimit, globalTvQuotaDays: defaultQuotas.tv.quotaDays, globalTvQuotaLimit: defaultQuotas.tv.quotaLimit, + watchlistSyncMovies: user.settings?.watchlistSyncMovies, + watchlistSyncTv: user.settings?.watchlistSyncTv, }); } catch (e) { next({ status: 500, message: e.message }); @@ -115,12 +117,16 @@ userSettingsRoutes.post< locale: req.body.locale, region: req.body.region, originalLanguage: req.body.originalLanguage, + watchlistSyncMovies: req.body.watchlistSyncMovies, + watchlistSyncTv: req.body.watchlistSyncTv, }); } else { user.settings.discordId = req.body.discordId; user.settings.locale = req.body.locale; user.settings.region = req.body.region; user.settings.originalLanguage = req.body.originalLanguage; + user.settings.watchlistSyncMovies = req.body.watchlistSyncMovies; + user.settings.watchlistSyncTv = req.body.watchlistSyncTv; user.email = req.body.email ?? user.email; } @@ -132,6 +138,8 @@ userSettingsRoutes.post< locale: user.settings.locale, region: user.settings.region, originalLanguage: user.settings.originalLanguage, + watchlistSyncMovies: user.settings.watchlistSyncMovies, + watchlistSyncTv: user.settings.watchlistSyncTv, email: user.email, }); } catch (e) { diff --git a/server/scripts/prepareTestDb.ts b/server/scripts/prepareTestDb.ts new file mode 100644 index 000000000..7caede41f --- /dev/null +++ b/server/scripts/prepareTestDb.ts @@ -0,0 +1,72 @@ +import { UserType } from '@server/constants/user'; +import dataSource, { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import { copyFileSync } from 'fs'; +import gravatarUrl from 'gravatar-url'; +import path from 'path'; + +const prepareDb = async () => { + // Copy over test settings.json + copyFileSync( + path.join(__dirname, '../../cypress/config/settings.cypress.json'), + path.join(__dirname, '../../config/settings.json') + ); + + // Connect to DB and seed test data + const dbConnection = await dataSource.initialize(); + + if (process.env.PRESERVE_DB !== 'true') { + await dbConnection.dropDatabase(); + } + + // Run migrations in production + if (process.env.WITH_MIGRATIONS === 'true') { + await dbConnection.runMigrations(); + } else { + await dbConnection.synchronize(); + } + + const userRepository = getRepository(User); + + const admin = await userRepository.findOne({ + select: { id: true, plexId: true }, + where: { id: 1 }, + }); + + // Create the admin user + const user = + (await userRepository.findOne({ + where: { email: 'admin@seerr.dev' }, + })) ?? new User(); + user.plexId = admin?.plexId ?? 1; + user.plexToken = '1234'; + user.plexUsername = 'admin'; + user.username = 'admin'; + user.email = 'admin@seerr.dev'; + user.userType = UserType.PLEX; + await user.setPassword('test1234'); + user.permissions = 2; + user.avatar = gravatarUrl('admin@seerr.dev', { default: 'mm', size: 200 }); + await userRepository.save(user); + + // Create the other user + const otherUser = + (await userRepository.findOne({ + where: { email: 'friend@seerr.dev' }, + })) ?? new User(); + otherUser.plexId = admin?.plexId ?? 1; + otherUser.plexToken = '1234'; + otherUser.plexUsername = 'friend'; + otherUser.username = 'friend'; + otherUser.email = 'friend@seerr.dev'; + otherUser.userType = UserType.PLEX; + await otherUser.setPassword('test1234'); + otherUser.permissions = 32; + otherUser.avatar = gravatarUrl('friend@seerr.dev', { + default: 'mm', + size: 200, + }); + await userRepository.save(otherUser); +}; + +prepareDb(); diff --git a/server/subscriber/IssueCommentSubscriber.ts b/server/subscriber/IssueCommentSubscriber.ts index 1b1b7b55c..cb95ba008 100644 --- a/server/subscriber/IssueCommentSubscriber.ts +++ b/server/subscriber/IssueCommentSubscriber.ts @@ -1,18 +1,15 @@ +import TheMovieDb from '@server/api/themoviedb'; +import { IssueType, IssueTypeName } from '@server/constants/issue'; +import { MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import IssueComment from '@server/entity/IssueComment'; +import Media from '@server/entity/Media'; +import notificationManager, { Notification } from '@server/lib/notifications'; +import { Permission } from '@server/lib/permissions'; +import logger from '@server/logger'; import { sortBy } from 'lodash'; -import { - EntitySubscriberInterface, - EventSubscriber, - getRepository, - InsertEvent, -} from 'typeorm'; -import TheMovieDb from '../api/themoviedb'; -import { IssueType, IssueTypeName } from '../constants/issue'; -import { MediaType } from '../constants/media'; -import IssueComment from '../entity/IssueComment'; -import Media from '../entity/Media'; -import notificationManager, { Notification } from '../lib/notifications'; -import { Permission } from '../lib/permissions'; -import logger from '../logger'; +import type { EntitySubscriberInterface, InsertEvent } from 'typeorm'; +import { EventSubscriber } from 'typeorm'; @EventSubscriber() export class IssueCommentSubscriber @@ -31,7 +28,7 @@ export class IssueCommentSubscriber const issue = ( await getRepository(IssueComment).findOneOrFail({ where: { id: entity.id }, - relations: ['issue'], + relations: { issue: true }, }) ).issue; @@ -72,6 +69,7 @@ export class IssueCommentSubscriber media, image, notifyAdmin: true, + notifySystem: true, notifyUser: !issue.createdBy.hasPermission(Permission.MANAGE_ISSUES) && issue.createdBy.id !== entity.user.id diff --git a/server/subscriber/IssueSubscriber.ts b/server/subscriber/IssueSubscriber.ts index b593095cd..eb4020415 100644 --- a/server/subscriber/IssueSubscriber.ts +++ b/server/subscriber/IssueSubscriber.ts @@ -1,17 +1,17 @@ +import TheMovieDb from '@server/api/themoviedb'; +import { IssueStatus, IssueType, IssueTypeName } from '@server/constants/issue'; +import { MediaType } from '@server/constants/media'; +import Issue from '@server/entity/Issue'; +import notificationManager, { Notification } from '@server/lib/notifications'; +import { Permission } from '@server/lib/permissions'; +import logger from '@server/logger'; import { sortBy } from 'lodash'; -import { +import type { EntitySubscriberInterface, - EventSubscriber, InsertEvent, UpdateEvent, } from 'typeorm'; -import TheMovieDb from '../api/themoviedb'; -import { IssueStatus, IssueType, IssueTypeName } from '../constants/issue'; -import { MediaType } from '../constants/media'; -import Issue from '../entity/Issue'; -import notificationManager, { Notification } from '../lib/notifications'; -import { Permission } from '../lib/permissions'; -import logger from '../logger'; +import { EventSubscriber } from 'typeorm'; @EventSubscriber() export class IssueSubscriber implements EntitySubscriberInterface { @@ -84,6 +84,7 @@ export class IssueSubscriber implements EntitySubscriberInterface { image, extra, notifyAdmin: true, + notifySystem: true, notifyUser: !entity.createdBy.hasPermission(Permission.MANAGE_ISSUES) && (type === Notification.ISSUE_RESOLVED || diff --git a/server/subscriber/MediaSubscriber.ts b/server/subscriber/MediaSubscriber.ts index 01752b0d1..eecfe6f3d 100644 --- a/server/subscriber/MediaSubscriber.ts +++ b/server/subscriber/MediaSubscriber.ts @@ -1,18 +1,18 @@ -import { truncate } from 'lodash'; +import TheMovieDb from '@server/api/themoviedb'; import { - EntitySubscriberInterface, - EventSubscriber, - getRepository, - Not, - UpdateEvent, -} from 'typeorm'; -import TheMovieDb from '../api/themoviedb'; -import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media'; -import Media from '../entity/Media'; -import { MediaRequest } from '../entity/MediaRequest'; -import Season from '../entity/Season'; -import notificationManager, { Notification } from '../lib/notifications'; -import logger from '../logger'; + MediaRequestStatus, + MediaStatus, + MediaType, +} from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { MediaRequest } from '@server/entity/MediaRequest'; +import Season from '@server/entity/Season'; +import notificationManager, { Notification } from '@server/lib/notifications'; +import logger from '@server/logger'; +import { truncate } from 'lodash'; +import type { EntitySubscriberInterface, UpdateEvent } from 'typeorm'; +import { EventSubscriber, In, Not } from 'typeorm'; @EventSubscriber() export class MediaSubscriber implements EntitySubscriberInterface { @@ -29,7 +29,9 @@ export class MediaSubscriber implements EntitySubscriberInterface { const requestRepository = getRepository(MediaRequest); const relatedRequests = await requestRepository.find({ where: { - media: entity, + media: { + id: entity.id, + }, is4k, status: Not(MediaRequestStatus.DECLINED), }, @@ -47,6 +49,7 @@ export class MediaSubscriber implements EntitySubscriberInterface { { event: `${is4k ? '4K ' : ''}Movie Request Now Available`, notifyAdmin: false, + notifySystem: true, notifyUser: request.requestedBy, subject: `${movie.title}${ movie.release_date @@ -89,7 +92,7 @@ export class MediaSubscriber implements EntitySubscriberInterface { ) .map((season) => season.seasonNumber); const oldSeasonIds = dbEntity.seasons.map((season) => season.id); - const oldSeasons = await seasonRepository.findByIds(oldSeasonIds); + const oldSeasons = await seasonRepository.findBy({ id: In(oldSeasonIds) }); const oldAvailableSeasons = oldSeasons .filter( (season) => @@ -109,7 +112,9 @@ export class MediaSubscriber implements EntitySubscriberInterface { for (const changedSeasonNumber of changedSeasons) { const requests = await requestRepository.find({ where: { - media: entity, + media: { + id: entity.id, + }, is4k, status: Not(MediaRequestStatus.DECLINED), }, @@ -143,6 +148,7 @@ export class MediaSubscriber implements EntitySubscriberInterface { omission: '…', }), notifyAdmin: false, + notifySystem: true, notifyUser: request.requestedBy, image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`, media: entity, @@ -172,7 +178,7 @@ export class MediaSubscriber implements EntitySubscriberInterface { const requestRepository = getRepository(MediaRequest); const requests = await requestRepository.find({ - where: { media: event.id }, + where: { media: { id: event.id } }, }); for (const request of requests) { diff --git a/server/tsconfig.json b/server/tsconfig.json index d245100d9..ec4b9004d 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -4,7 +4,11 @@ "target": "ES2020", "module": "commonjs", "outDir": "../dist", - "noEmit": false + "noEmit": false, + "baseUrl": ".", + "paths": { + "@server/*": ["*"] + } }, "include": ["**/*.ts"] } diff --git a/server/types/express.d.ts b/server/types/express.d.ts index ee7fd9724..7b82477ad 100644 --- a/server/types/express.d.ts +++ b/server/types/express.d.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ +import type { User } from '@server/entity/User'; import type { NextFunction, Request, Response } from 'express'; -import type { User } from '../entity/User'; declare global { namespace Express { diff --git a/server/utils/appVersion.ts b/server/utils/appVersion.ts index 923d47089..d01a08a97 100644 --- a/server/utils/appVersion.ts +++ b/server/utils/appVersion.ts @@ -1,6 +1,6 @@ +import logger from '@server/logger'; import { existsSync } from 'fs'; import path from 'path'; -import logger from '../logger'; const COMMIT_TAG_PATH = path.join(__dirname, '../../committag.json'); let commitTag = 'local'; diff --git a/server/utils/dateHelpers.ts b/server/utils/dateHelpers.ts new file mode 100644 index 000000000..4684d7835 --- /dev/null +++ b/server/utils/dateHelpers.ts @@ -0,0 +1,4 @@ +import { addYears } from 'date-fns'; +import { Between } from 'typeorm'; + +export const AfterDate = (date: Date) => Between(date, addYears(date, 100)); diff --git a/server/utils/restartFlag.ts b/server/utils/restartFlag.ts new file mode 100644 index 000000000..387ec5ce4 --- /dev/null +++ b/server/utils/restartFlag.ts @@ -0,0 +1,23 @@ +import type { MainSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; + +class RestartFlag { + private settings: MainSettings; + + public initializeSettings(settings: MainSettings): void { + this.settings = { ...settings }; + } + + public isSet(): boolean { + const settings = getSettings().main; + + return ( + this.settings.csrfProtection !== settings.csrfProtection || + this.settings.trustProxy !== settings.trustProxy + ); + } +} + +const restartFlag = new RestartFlag(); + +export default restartFlag; diff --git a/server/utils/typeHelpers.ts b/server/utils/typeHelpers.ts index 04070244b..507ece8cd 100644 --- a/server/utils/typeHelpers.ts +++ b/server/utils/typeHelpers.ts @@ -5,7 +5,7 @@ import type { TmdbPersonResult, TmdbTvDetails, TmdbTvResult, -} from '../api/themoviedb/interfaces'; +} from '@server/api/themoviedb/interfaces'; export const isMovie = ( movie: TmdbMovieResult | TmdbTvResult | TmdbPersonResult diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 0a7099ccf..3b693643a 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -8,10 +8,15 @@ description: > base: core18 confinement: strict +architectures: + - build-on: amd64 + - build-on: arm64 + - build-on: armhf + parts: overseerr: plugin: nodejs - nodejs-version: '16.14.0' + nodejs-version: '16.17.0' nodejs-package-manager: 'yarn' nodejs-yarn-version: v1.22.17 build-packages: @@ -31,13 +36,16 @@ parts: override-pull: | snapcraftctl pull # Get information to determine snap grade and version + git config --global --add safe.directory /data/parts/overseerr/src + #setup yarn.rc + echo "--install.frozen-lockfile\n--install.network-timeout 1000000" > .yarnrc BRANCH=$(git rev-parse --abbrev-ref HEAD) COMMIT=$(git rev-parse HEAD) COMMIT_SHORT=$(git rev-parse --short HEAD) VERSION='v'$(cat package.json | grep 'version' | head -1 | sed 's/.*"\(.*\)"\,/\1/') if [ "$VERSION" = "v0.1.0" ]; then SNAP_VERSION=$COMMIT_SHORT - GRADE=devel + GRADE=stable else SNAP_VERSION=$VERSION GRADE=stable @@ -57,6 +65,7 @@ parts: snapcraftctl set-grade "$GRADE" build-environment: - PATH: '$SNAPCRAFT_PART_BUILD/node_modules/.bin:$SNAPCRAFT_PART_BUILD/../npm/bin:$PATH' + - CYPRESS_INSTALL_BINARY: '0' override-build: | set -e # Set COMMIT_TAG before the build begins @@ -77,7 +86,7 @@ parts: prime: [.next, ./*] apps: - deamon: + daemon: command: /bin/sh -c "cd $SNAP && node dist/index.js" daemon: simple restart-condition: on-failure diff --git a/src/assets/infinity.svg b/src/assets/infinity.svg new file mode 100644 index 000000000..054149f8e --- /dev/null +++ b/src/assets/infinity.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/AirDateBadge/index.tsx b/src/components/AirDateBadge/index.tsx new file mode 100644 index 000000000..fb9268f6c --- /dev/null +++ b/src/components/AirDateBadge/index.tsx @@ -0,0 +1,62 @@ +import Badge from '@app/components/Common/Badge'; +import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; + +const messages = defineMessages({ + airedrelative: 'Aired {relativeTime}', + airsrelative: 'Airing {relativeTime}', +}); + +type AirDateBadgeProps = { + airDate: string; +}; + +const AirDateBadge = ({ airDate }: AirDateBadgeProps) => { + const WEEK = 1000 * 60 * 60 * 24 * 8; + const intl = useIntl(); + const dAirDate = new Date(airDate); + const nowDate = new Date(); + const alreadyAired = dAirDate.getTime() < nowDate.getTime(); + + const compareWeek = new Date( + alreadyAired ? Date.now() - WEEK : Date.now() + WEEK + ); + + let showRelative = false; + + if ( + (alreadyAired && dAirDate.getTime() > compareWeek.getTime()) || + (!alreadyAired && dAirDate.getTime() < compareWeek.getTime()) + ) { + showRelative = true; + } + + return ( +
+ + {intl.formatDate(dAirDate, { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + + {showRelative && ( + + {intl.formatMessage( + alreadyAired ? messages.airedrelative : messages.airsrelative, + { + relativeTime: ( + + ), + } + )} + + )} +
+ ); +}; + +export default AirDateBadge; diff --git a/src/components/AppDataWarning/index.tsx b/src/components/AppDataWarning/index.tsx index fce97bd53..21c3dbaef 100644 --- a/src/components/AppDataWarning/index.tsx +++ b/src/components/AppDataWarning/index.tsx @@ -1,14 +1,13 @@ -import React from 'react'; +import Alert from '@app/components/Common/Alert'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import Alert from '../Common/Alert'; const messages = defineMessages({ dockerVolumeMissingDescription: 'The {appDataPath} volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.', }); -const AppDataWarning: React.FC = () => { +const AppDataWarning = () => { const intl = useIntl(); const { data, error } = useSWR<{ appData: boolean; appDataPath: string }>( '/api/v1/status/appdata' @@ -27,9 +26,9 @@ const AppDataWarning: React.FC = () => { {!data.appData && ( {msg}; - }, + code: (msg: React.ReactNode) => ( + {msg} + ), appDataPath: data.appDataPath, })} /> diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index 839f019ad..52bd8a269 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -1,24 +1,24 @@ +import ButtonWithDropdown from '@app/components/Common/ButtonWithDropdown'; +import CachedImage from '@app/components/Common/CachedImage'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import RequestModal from '@app/components/RequestModal'; +import Slider from '@app/components/Slider'; +import StatusBadge from '@app/components/StatusBadge'; +import TitleCard from '@app/components/TitleCard'; +import useSettings from '@app/hooks/useSettings'; +import { Permission, useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; +import Error from '@app/pages/_error'; import { DownloadIcon } from '@heroicons/react/outline'; +import { MediaStatus } from '@server/constants/media'; +import type { Collection } from '@server/models/Collection'; import { uniq } from 'lodash'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React, { useState } from 'react'; +import { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import { MediaStatus } from '../../../server/constants/media'; -import type { Collection } from '../../../server/models/Collection'; -import useSettings from '../../hooks/useSettings'; -import { Permission, useUser } from '../../hooks/useUser'; -import globalMessages from '../../i18n/globalMessages'; -import Error from '../../pages/_error'; -import ButtonWithDropdown from '../Common/ButtonWithDropdown'; -import CachedImage from '../Common/CachedImage'; -import LoadingSpinner from '../Common/LoadingSpinner'; -import PageTitle from '../Common/PageTitle'; -import RequestModal from '../RequestModal'; -import Slider from '../Slider'; -import StatusBadge from '../StatusBadge'; -import TitleCard from '../TitleCard'; const messages = defineMessages({ overview: 'Overview', @@ -31,9 +31,7 @@ interface CollectionDetailsProps { collection?: Collection; } -const CollectionDetails: React.FC = ({ - collection, -}) => { +const CollectionDetails = ({ collection }: CollectionDetailsProps) => { const intl = useIntl(); const router = useRouter(); const settings = useSettings(); diff --git a/src/components/Common/Accordion/index.tsx b/src/components/Common/Accordion/index.tsx index 67e883fe0..49187bd03 100644 --- a/src/components/Common/Accordion/index.tsx +++ b/src/components/Common/Accordion/index.tsx @@ -1,9 +1,9 @@ -import * as React from 'react'; +import type * as React from 'react'; import { useState } from 'react'; import AnimateHeight from 'react-animate-height'; export interface AccordionProps { - children: (args: AccordionChildProps) => React.ReactElement | null; + children: (args: AccordionChildProps) => JSX.Element; /** If true, only one accordion item can be open at any time */ single?: boolean; /** If true, at least one accordion item will always be open */ @@ -13,22 +13,27 @@ export interface AccordionProps { export interface AccordionChildProps { openIndexes: number[]; handleClick(index: number): void; - AccordionContent: any; + AccordionContent: typeof AccordionContent; } -export const AccordionContent: React.FC<{ isOpen: boolean }> = ({ +type AccordionContentProps = { + isOpen: boolean; + children: React.ReactNode; +}; + +export const AccordionContent = ({ isOpen, children, -}) => { +}: AccordionContentProps) => { return {children}; }; -const Accordion: React.FC = ({ +const Accordion = ({ single, atLeastOne, initialOpenIndexes, children, -}) => { +}: AccordionProps) => { const initialState = initialOpenIndexes || (atLeastOne && [0]) || []; const [openIndexes, setOpenIndexes] = useState(initialState); diff --git a/src/components/Common/Alert/index.tsx b/src/components/Common/Alert/index.tsx index e9789c706..8ffb4a255 100644 --- a/src/components/Common/Alert/index.tsx +++ b/src/components/Common/Alert/index.tsx @@ -3,16 +3,17 @@ import { InformationCircleIcon, XCircleIcon, } from '@heroicons/react/solid'; -import React from 'react'; interface AlertProps { title?: React.ReactNode; type?: 'warning' | 'info' | 'error'; + children?: React.ReactNode; } -const Alert: React.FC = ({ title, children, type }) => { +const Alert = ({ title, children, type }: AlertProps) => { let design = { - bgColor: 'bg-yellow-600', + bgColor: + 'border border-yellow-500 backdrop-blur bg-yellow-400 bg-opacity-20', titleColor: 'text-yellow-100', textColor: 'text-yellow-300', svg: , @@ -21,9 +22,10 @@ const Alert: React.FC = ({ title, children, type }) => { switch (type) { case 'info': design = { - bgColor: 'bg-indigo-600', - titleColor: 'text-indigo-100', - textColor: 'text-indigo-300', + bgColor: + 'border border-indigo-500 backdrop-blur bg-indigo-400 bg-opacity-20', + titleColor: 'text-gray-100', + textColor: 'text-gray-300', svg: , }; break; diff --git a/src/components/Common/Badge/index.tsx b/src/components/Common/Badge/index.tsx index 33e55ab72..47ce6586c 100644 --- a/src/components/Common/Badge/index.tsx +++ b/src/components/Common/Badge/index.tsx @@ -2,17 +2,23 @@ import Link from 'next/link'; import React from 'react'; interface BadgeProps { - badgeType?: 'default' | 'primary' | 'danger' | 'warning' | 'success'; + badgeType?: + | 'default' + | 'primary' + | 'danger' + | 'warning' + | 'success' + | 'dark' + | 'light'; className?: string; href?: string; + children: React.ReactNode; } -const Badge: React.FC = ({ - badgeType = 'default', - className, - href, - children, -}) => { +const Badge = ( + { badgeType = 'default', className, href, children }: BadgeProps, + ref?: React.Ref +) => { const badgeStyle = [ 'px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap', ]; @@ -25,27 +31,47 @@ const Badge: React.FC = ({ switch (badgeType) { case 'danger': - badgeStyle.push('bg-red-600 !text-red-100'); + badgeStyle.push( + 'bg-red-600 bg-opacity-80 border-red-500 border !text-red-100' + ); if (href) { - badgeStyle.push('hover:bg-red-500'); + badgeStyle.push('hover:bg-red-500 bg-opacity-100'); } break; case 'warning': - badgeStyle.push('bg-yellow-500 !text-yellow-100'); + badgeStyle.push( + 'bg-yellow-500 bg-opacity-80 border-yellow-500 border !text-yellow-100' + ); if (href) { - badgeStyle.push('hover:bg-yellow-400'); + badgeStyle.push('hover:bg-yellow-500 hover:bg-opacity-100'); } break; case 'success': - badgeStyle.push('bg-green-500 !text-green-100'); + badgeStyle.push( + 'bg-green-500 bg-opacity-80 border border-green-500 !text-green-100' + ); if (href) { - badgeStyle.push('hover:bg-green-400'); + badgeStyle.push('hover:bg-green-500 hover:bg-opacity-100'); + } + break; + case 'dark': + badgeStyle.push('bg-gray-900 !text-gray-400'); + if (href) { + badgeStyle.push('hover:bg-gray-800'); + } + break; + case 'light': + badgeStyle.push('bg-gray-700 !text-gray-300'); + if (href) { + badgeStyle.push('hover:bg-gray-600'); } break; default: - badgeStyle.push('bg-indigo-500 !text-indigo-100'); + badgeStyle.push( + 'bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100' + ); if (href) { - badgeStyle.push('hover:bg-indigo-400'); + badgeStyle.push('hover:bg-indigo-500 bg-opacity-100'); } } @@ -60,6 +86,7 @@ const Badge: React.FC = ({ target="_blank" rel="noopener noreferrer" className={badgeStyle.join(' ')} + ref={ref as React.Ref} > {children} @@ -67,12 +94,24 @@ const Badge: React.FC = ({ } else if (href) { return ( - {children} + } + > + {children} + ); } else { - return {children}; + return ( + } + > + {children} + + ); } }; -export default Badge; +export default React.forwardRef(Badge) as typeof Badge; diff --git a/src/components/Common/Button/index.tsx b/src/components/Common/Button/index.tsx index f1083e5b2..7dc4e637d 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 bg-yellow-500 bg-opacity-80 hover:bg-opacity-100 hover:border-yellow-400 focus:border-yellow-700 focus:ring-yellow active:bg-opacity-100 active:border-yellow-700' ); break; case 'success': buttonStyle.push( - 'text-white bg-green-500 border-green-500 hover:bg-green-400 hover:border-green-400 focus:border-green-700 focus:ring-green active:bg-green-700 active:border-green-700' + 'text-white bg-green-500 bg-opacity-80 border-green-500 hover:bg-opacity-100 hover:border-green-400 focus:border-green-700 focus:ring-green active:bg-opacity-100 active:border-green-700' ); break; case 'ghost': @@ -75,7 +76,7 @@ function Button

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

-

- - {children} - +

+ {children}

{subtext &&
{subtext}
}
diff --git a/src/components/Common/ImageFader/index.tsx b/src/components/Common/ImageFader/index.tsx index 5f68376c0..a57172414 100644 --- a/src/components/Common/ImageFader/index.tsx +++ b/src/components/Common/ImageFader/index.tsx @@ -1,10 +1,6 @@ -import React, { - ForwardRefRenderFunction, - HTMLAttributes, - useEffect, - useState, -} from 'react'; -import CachedImage from '../CachedImage'; +import CachedImage from '@app/components/Common/CachedImage'; +import type { ForwardRefRenderFunction, HTMLAttributes } from 'react'; +import React, { useEffect, useState } from 'react'; interface ImageFaderProps extends HTMLAttributes { backgroundImages: string[]; diff --git a/src/components/Common/List/index.tsx b/src/components/Common/List/index.tsx index a4b917235..32057ed1a 100644 --- a/src/components/Common/List/index.tsx +++ b/src/components/Common/List/index.tsx @@ -1,12 +1,12 @@ -import React from 'react'; -import { withProperties } from '../../../utils/typeHelpers'; +import { withProperties } from '@app/utils/typeHelpers'; interface ListItemProps { title: string; className?: string; + children: React.ReactNode; } -const ListItem: React.FC = ({ title, className, children }) => { +const ListItem = ({ title, className, children }: ListItemProps) => { return (
@@ -22,9 +22,10 @@ const ListItem: React.FC = ({ title, className, children }) => { interface ListProps { title: string; subTitle?: string; + children: React.ReactNode; } -const List: React.FC = ({ title, subTitle, children }) => { +const List = ({ title, subTitle, children }: ListProps) => { return ( <>
diff --git a/src/components/Common/ListView/index.tsx b/src/components/Common/ListView/index.tsx index 0c2a0e4ed..6f09f768b 100644 --- a/src/components/Common/ListView/index.tsx +++ b/src/components/Common/ListView/index.tsx @@ -1,30 +1,33 @@ -import React from 'react'; -import { useIntl } from 'react-intl'; -import { +import PersonCard from '@app/components/PersonCard'; +import TitleCard from '@app/components/TitleCard'; +import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard'; +import useVerticalScroll from '@app/hooks/useVerticalScroll'; +import globalMessages from '@app/i18n/globalMessages'; +import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces'; +import type { MovieResult, PersonResult, TvResult, -} from '../../../../server/models/Search'; -import useVerticalScroll from '../../../hooks/useVerticalScroll'; -import globalMessages from '../../../i18n/globalMessages'; -import PersonCard from '../../PersonCard'; -import TitleCard from '../../TitleCard'; +} from '@server/models/Search'; +import { useIntl } from 'react-intl'; -interface ListViewProps { +type ListViewProps = { items?: (TvResult | MovieResult | PersonResult)[]; + plexItems?: WatchlistItem[]; isEmpty?: boolean; isLoading?: boolean; isReachingEnd?: boolean; onScrollBottom: () => void; -} +}; -const ListView: React.FC = ({ +const ListView = ({ items, isEmpty, isLoading, onScrollBottom, isReachingEnd, -}) => { + plexItems, +}: ListViewProps) => { const intl = useIntl(); useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd); return ( @@ -35,6 +38,18 @@ const ListView: React.FC = ({
)}
    + {plexItems?.map((title, index) => { + return ( +
  • + +
  • + ); + })} {items?.map((title, index) => { let titleCard: React.ReactNode; diff --git a/src/components/Common/LoadingSpinner/index.tsx b/src/components/Common/LoadingSpinner/index.tsx index be65f0094..8f922ef38 100644 --- a/src/components/Common/LoadingSpinner/index.tsx +++ b/src/components/Common/LoadingSpinner/index.tsx @@ -1,6 +1,4 @@ -import React from 'react'; - -export const SmallLoadingSpinner: React.FC = () => { +export const SmallLoadingSpinner = () => { return (
    { ); }; -const LoadingSpinner: React.FC = () => { +const LoadingSpinner = () => { return (
    ) => void; onOk?: (e?: MouseEvent) => void; onSecondary?: (e?: MouseEvent) => void; @@ -28,87 +31,94 @@ interface ModalProps { tertiaryButtonType?: ButtonType; disableScrollLock?: boolean; backgroundClickable?: boolean; - iconSvg?: ReactNode; loading?: boolean; backdrop?: string; + children?: React.ReactNode; } -const Modal: React.FC = ({ - title, - onCancel, - onOk, - cancelText, - okText, - okDisabled = false, - cancelButtonType = 'default', - okButtonType = 'primary', - children, - disableScrollLock, - backgroundClickable = true, - iconSvg, - loading = false, - secondaryButtonType = 'default', - secondaryDisabled = false, - onSecondary, - secondaryText, - tertiaryButtonType = 'default', - tertiaryDisabled = false, - tertiaryText, - onTertiary, - backdrop, -}) => { - const intl = useIntl(); - const modalRef = useRef(null); - useClickOutside(modalRef, () => { - typeof onCancel === 'function' && backgroundClickable - ? onCancel() - : undefined; - }); - useLockBodyScroll(true, disableScrollLock); +const Modal = React.forwardRef( + ( + { + title, + subTitle, + onCancel, + onOk, + cancelText, + okText, + okDisabled = false, + cancelButtonType = 'default', + okButtonType = 'primary', + children, + disableScrollLock, + backgroundClickable = true, + secondaryButtonType = 'default', + secondaryDisabled = false, + onSecondary, + secondaryText, + tertiaryButtonType = 'default', + tertiaryDisabled = false, + tertiaryText, + loading = false, + onTertiary, + backdrop, + }, + parentRef + ) => { + const intl = useIntl(); + const modalRef = useRef(null); + useClickOutside(modalRef, () => { + if (onCancel && backgroundClickable) { + onCancel(); + } + }); + useLockBodyScroll(true, disableScrollLock); - return ReactDOM.createPortal( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions -
    { - if (e.key === 'Escape') { - typeof onCancel === 'function' && backgroundClickable - ? onCancel() - : undefined; - } - }} - > - -
    - -
    -
    - -
    +
    + +
    + + {backdrop && (
    @@ -123,30 +133,45 @@ const Modal: React.FC = ({ className="absolute inset-0" style={{ backgroundImage: - 'linear-gradient(180deg, rgba(55, 65, 81, 0.85) 0%, rgba(55, 65, 81, 1) 100%)', + 'linear-gradient(180deg, rgba(31, 41, 55, 0.75) 0%, rgba(31, 41, 55, 1) 100%)', }} />
    )} -
    - {iconSvg &&
    {iconSvg}
    } +
    - {title && ( - - {title} - + {(title || subTitle) && ( +
    + {title && ( + + {title} + + )} + {subTitle && ( + + {subTitle} + + )} +
    )}
    {children && ( -
    +
    {children}
    )} @@ -158,6 +183,7 @@ const Modal: React.FC = ({ onClick={onOk} className="ml-3" disabled={okDisabled} + data-testid="modal-ok-button" > {okText ? okText : 'Ok'} @@ -168,6 +194,7 @@ const Modal: React.FC = ({ onClick={onSecondary} className="ml-3" disabled={secondaryDisabled} + data-testid="modal-secondary-button" > {secondaryText} @@ -187,6 +214,7 @@ const Modal: React.FC = ({ buttonType={cancelButtonType} onClick={onCancel} className="ml-3 sm:ml-0" + data-testid="modal-cancel-button" > {cancelText ? cancelText @@ -195,11 +223,13 @@ const Modal: React.FC = ({ )}
    )} -
    -
    -
    , - document.body - ); -}; +
    + , + document.body + ); + } +); + +Modal.displayName = 'Modal'; export default Modal; diff --git a/src/components/Common/PageTitle/index.tsx b/src/components/Common/PageTitle/index.tsx index a7224b226..288a0b374 100644 --- a/src/components/Common/PageTitle/index.tsx +++ b/src/components/Common/PageTitle/index.tsx @@ -1,20 +1,20 @@ -import React from 'react'; -import useSettings from '../../../hooks/useSettings'; +import useSettings from '@app/hooks/useSettings'; import Head from 'next/head'; interface PageTitleProps { title: string | (string | undefined)[]; } -const PageTitle: React.FC = ({ title }) => { +const PageTitle = ({ title }: PageTitleProps) => { const settings = useSettings(); + const titleText = `${ + Array.isArray(title) ? title.filter(Boolean).join(' - ') : title + } - ${settings.currentSettings.applicationTitle}`; + return ( - - {Array.isArray(title) ? title.filter(Boolean).join(' - ') : title} -{' '} - {settings.currentSettings.applicationTitle} - + {titleText} ); }; diff --git a/src/components/Common/PlayButton/index.tsx b/src/components/Common/PlayButton/index.tsx index c41935aee..01d3a0121 100644 --- a/src/components/Common/PlayButton/index.tsx +++ b/src/components/Common/PlayButton/index.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode } from 'react'; -import ButtonWithDropdown from '../ButtonWithDropdown'; +import ButtonWithDropdown from '@app/components/Common/ButtonWithDropdown'; interface PlayButtonProps { links: PlayButtonLink[]; @@ -8,10 +7,10 @@ interface PlayButtonProps { export interface PlayButtonLink { text: string; url: string; - svg: ReactNode; + svg: React.ReactNode; } -const PlayButton: React.FC = ({ links }) => { +const PlayButton = ({ links }: PlayButtonProps) => { if (!links || !links.length) { return null; } diff --git a/src/components/Common/ProgressCircle/index.tsx b/src/components/Common/ProgressCircle/index.tsx index 64ca49c17..7df2b041e 100644 --- a/src/components/Common/ProgressCircle/index.tsx +++ b/src/components/Common/ProgressCircle/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from 'react'; +import { useEffect, useRef } from 'react'; interface ProgressCircleProps { className?: string; @@ -6,11 +6,11 @@ interface ProgressCircleProps { useHeatLevel?: boolean; } -const ProgressCircle: React.FC = ({ +const ProgressCircle = ({ className, progress = 0, useHeatLevel, -}) => { +}: ProgressCircleProps) => { const ref = useRef(null); let color = ''; diff --git a/src/components/Common/SensitiveInput/index.tsx b/src/components/Common/SensitiveInput/index.tsx index 886fd7217..6652f5519 100644 --- a/src/components/Common/SensitiveInput/index.tsx +++ b/src/components/Common/SensitiveInput/index.tsx @@ -1,6 +1,6 @@ import { EyeIcon, EyeOffIcon } from '@heroicons/react/solid'; import { Field } from 'formik'; -import React, { useState } from 'react'; +import { useState } from 'react'; interface CustomInputProps extends React.ComponentProps<'input'> { as?: 'input'; @@ -12,10 +12,7 @@ interface CustomFieldProps extends React.ComponentProps { type SensitiveInputProps = CustomInputProps | CustomFieldProps; -const SensitiveInput: React.FC = ({ - as = 'input', - ...props -}) => { +const SensitiveInput = ({ as = 'input', ...props }: SensitiveInputProps) => { const [isHidden, setHidden] = useState(true); const Component = as === 'input' ? 'input' : Field; const componentProps = diff --git a/src/components/Common/SettingsTabs/index.tsx b/src/components/Common/SettingsTabs/index.tsx index 751587050..8ee46fea3 100644 --- a/src/components/Common/SettingsTabs/index.tsx +++ b/src/components/Common/SettingsTabs/index.tsx @@ -1,8 +1,8 @@ +import { useUser } from '@app/hooks/useUser'; +import type { Permission } from '@server/lib/permissions'; +import { hasPermission } from '@server/lib/permissions'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React from 'react'; -import { hasPermission, Permission } from '../../../../server/lib/permissions'; -import { useUser } from '../../../hooks/useUser'; export interface SettingsRoute { text: string; @@ -14,14 +14,17 @@ export interface SettingsRoute { hidden?: boolean; } -const SettingsLink: React.FC<{ +type SettingsLinkProps = { tabType: 'default' | 'button'; currentPath: string; route: string; regex: RegExp; hidden?: boolean; isMobile?: boolean; -}> = ({ + children: React.ReactNode; +}; + +const SettingsLink = ({ children, tabType, currentPath, @@ -29,7 +32,7 @@ const SettingsLink: React.FC<{ regex, hidden = false, isMobile = false, -}) => { +}: SettingsLinkProps) => { if (hidden) { return null; } @@ -65,10 +68,13 @@ const SettingsLink: React.FC<{ ); }; -const SettingsTabs: React.FC<{ +const SettingsTabs = ({ + tabType = 'default', + settingsRoutes, +}: { tabType?: 'default' | 'button'; settingsRoutes: SettingsRoute[]; -}> = ({ tabType = 'default', settingsRoutes }) => { +}) => { const router = useRouter(); const { user: currentUser } = useUser(); @@ -137,7 +143,7 @@ const SettingsTabs: React.FC<{
    ) : (
    -
    diff --git a/src/components/Common/StatusBadgeMini/index.tsx b/src/components/Common/StatusBadgeMini/index.tsx new file mode 100644 index 000000000..0653c7d80 --- /dev/null +++ b/src/components/Common/StatusBadgeMini/index.tsx @@ -0,0 +1,45 @@ +import { + BellIcon, + CheckIcon, + ClockIcon, + MinusSmIcon, +} from '@heroicons/react/solid'; +import { MediaStatus } from '@server/constants/media'; + +interface StatusBadgeMiniProps { + status: MediaStatus; + is4k?: boolean; +} + +const StatusBadgeMini = ({ status, is4k = false }: StatusBadgeMiniProps) => { + const badgeStyle = ['w-5 rounded-full p-0.5 text-white ring-1']; + let indicatorIcon: React.ReactNode; + + switch (status) { + case MediaStatus.PROCESSING: + badgeStyle.push('bg-indigo-500 ring-indigo-400'); + indicatorIcon = ; + break; + case MediaStatus.AVAILABLE: + badgeStyle.push('bg-green-500 ring-green-400'); + indicatorIcon = ; + break; + case MediaStatus.PENDING: + badgeStyle.push('bg-yellow-500 ring-yellow-400'); + indicatorIcon = ; + break; + case MediaStatus.PARTIALLY_AVAILABLE: + badgeStyle.push('bg-green-500 ring-green-400'); + indicatorIcon = ; + break; + } + + return ( +
    +
    {indicatorIcon}
    + {is4k && 4K} +
    + ); +}; + +export default StatusBadgeMini; diff --git a/src/components/Common/Table/index.tsx b/src/components/Common/Table/index.tsx index 9e0cb0ca5..a286de69d 100644 --- a/src/components/Common/Table/index.tsx +++ b/src/components/Common/Table/index.tsx @@ -1,17 +1,20 @@ -import React, { AllHTMLAttributes } from 'react'; -import { withProperties } from '../../../utils/typeHelpers'; +import { withProperties } from '@app/utils/typeHelpers'; -const TBody: React.FC = ({ children }) => { +type TBodyProps = { + children: React.ReactNode; +}; + +const TBody = ({ children }: TBodyProps) => { return ( {children} ); }; -const TH: React.FC> = ({ +const TH = ({ children, className, ...props -}) => { +}: React.ComponentPropsWithoutRef<'th'>) => { const style = [ 'px-4 py-3 bg-gray-500 text-left text-xs leading-4 font-medium text-gray-200 uppercase tracking-wider truncate', ]; @@ -27,18 +30,18 @@ const TH: React.FC> = ({ ); }; -interface TDProps extends AllHTMLAttributes { +type TDProps = { alignText?: 'left' | 'center' | 'right'; noPadding?: boolean; -} +}; -const TD: React.FC = ({ +const TD = ({ children, alignText = 'left', noPadding, className, ...props -}) => { +}: TDProps & React.ComponentPropsWithoutRef<'td'>) => { const style = ['text-sm leading-5 text-white']; switch (alignText) { @@ -68,7 +71,11 @@ const TD: React.FC = ({ ); }; -const Table: React.FC = ({ children }) => { +type TableProps = { + children: React.ReactNode; +}; + +const Table = ({ children }: TableProps) => { return (
    diff --git a/src/components/Common/Tooltip/index.tsx b/src/components/Common/Tooltip/index.tsx new file mode 100644 index 000000000..82bc7a7a9 --- /dev/null +++ b/src/components/Common/Tooltip/index.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import type { Config } from 'react-popper-tooltip'; +import { usePopperTooltip } from 'react-popper-tooltip'; + +type TooltipProps = { + content: React.ReactNode; + children: React.ReactElement; + tooltipConfig?: Partial; +}; + +const Tooltip = ({ children, content, tooltipConfig }: TooltipProps) => { + const { getTooltipProps, setTooltipRef, setTriggerRef, visible } = + usePopperTooltip({ + followCursor: true, + offset: [-28, 6], + placement: 'auto-end', + ...tooltipConfig, + }); + + return ( + <> + {React.cloneElement(children, { ref: setTriggerRef })} + {visible && content && ( +
    + {content} +
    + )} + + ); +}; + +export default Tooltip; diff --git a/src/components/CompanyCard/index.tsx b/src/components/CompanyCard/index.tsx index b6383a77a..762d1a08b 100644 --- a/src/components/CompanyCard/index.tsx +++ b/src/components/CompanyCard/index.tsx @@ -1,5 +1,5 @@ import Link from 'next/link'; -import React, { useState } from 'react'; +import { useState } from 'react'; interface CompanyCardProps { name: string; @@ -7,7 +7,7 @@ interface CompanyCardProps { url: string; } -const CompanyCard: React.FC = ({ image, url, name }) => { +const CompanyCard = ({ image, url, name }: CompanyCardProps) => { const [isHovered, setHovered] = useState(false); return ( diff --git a/src/components/Discover/DiscoverMovieGenre/index.tsx b/src/components/Discover/DiscoverMovieGenre/index.tsx index e340f4eb9..d31921da6 100644 --- a/src/components/Discover/DiscoverMovieGenre/index.tsx +++ b/src/components/Discover/DiscoverMovieGenre/index.tsx @@ -1,19 +1,18 @@ -import React from 'react'; -import type { MovieResult } from '../../../../server/models/Search'; -import ListView from '../../Common/ListView'; -import { defineMessages, useIntl } from 'react-intl'; -import Header from '../../Common/Header'; -import PageTitle from '../../Common/PageTitle'; +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 globalMessages from '@app/i18n/globalMessages'; +import Error from '@app/pages/_error'; +import type { MovieResult } from '@server/models/Search'; import { useRouter } from 'next/router'; -import globalMessages from '../../../i18n/globalMessages'; -import useDiscover from '../../../hooks/useDiscover'; -import Error from '../../../pages/_error'; +import { defineMessages, useIntl } from 'react-intl'; const messages = defineMessages({ genreMovies: '{genre} Movies', }); -const DiscoverMovieGenre: React.FC = () => { +const DiscoverMovieGenre = () => { const router = useRouter(); const intl = useIntl(); diff --git a/src/components/Discover/DiscoverMovieLanguage/index.tsx b/src/components/Discover/DiscoverMovieLanguage/index.tsx index b1e19d055..e9a274fa8 100644 --- a/src/components/Discover/DiscoverMovieLanguage/index.tsx +++ b/src/components/Discover/DiscoverMovieLanguage/index.tsx @@ -1,19 +1,18 @@ -import React from 'react'; -import type { MovieResult } from '../../../../server/models/Search'; -import ListView from '../../Common/ListView'; -import { defineMessages, useIntl } from 'react-intl'; -import Header from '../../Common/Header'; -import PageTitle from '../../Common/PageTitle'; +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 globalMessages from '@app/i18n/globalMessages'; +import Error from '@app/pages/_error'; +import type { MovieResult } from '@server/models/Search'; import { useRouter } from 'next/router'; -import globalMessages from '../../../i18n/globalMessages'; -import useDiscover from '../../../hooks/useDiscover'; -import Error from '../../../pages/_error'; +import { defineMessages, useIntl } from 'react-intl'; const messages = defineMessages({ languageMovies: '{language} Movies', }); -const DiscoverMovieLanguage: React.FC = () => { +const DiscoverMovieLanguage = () => { const router = useRouter(); const intl = useIntl(); diff --git a/src/components/Discover/DiscoverMovies.tsx b/src/components/Discover/DiscoverMovies.tsx index cef4c6230..b9ec8dea8 100644 --- a/src/components/Discover/DiscoverMovies.tsx +++ b/src/components/Discover/DiscoverMovies.tsx @@ -1,17 +1,16 @@ -import React from 'react'; -import type { MovieResult } from '../../../server/models/Search'; -import ListView from '../Common/ListView'; +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 { MovieResult } from '@server/models/Search'; import { defineMessages, useIntl } from 'react-intl'; -import Header from '../Common/Header'; -import PageTitle from '../Common/PageTitle'; -import useDiscover from '../../hooks/useDiscover'; -import Error from '../../pages/_error'; const messages = defineMessages({ discovermovies: 'Popular Movies', }); -const DiscoverMovies: React.FC = () => { +const DiscoverMovies = () => { const intl = useIntl(); const { diff --git a/src/components/Discover/DiscoverNetwork/index.tsx b/src/components/Discover/DiscoverNetwork/index.tsx index 247c5ece7..f09fef378 100644 --- a/src/components/Discover/DiscoverNetwork/index.tsx +++ b/src/components/Discover/DiscoverNetwork/index.tsx @@ -1,20 +1,19 @@ -import React from 'react'; -import type { TvResult } from '../../../../server/models/Search'; -import ListView from '../../Common/ListView'; -import { defineMessages, useIntl } from 'react-intl'; -import Header from '../../Common/Header'; -import PageTitle from '../../Common/PageTitle'; +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 globalMessages from '@app/i18n/globalMessages'; +import Error from '@app/pages/_error'; +import type { TvNetwork } from '@server/models/common'; +import type { TvResult } from '@server/models/Search'; import { useRouter } from 'next/router'; -import globalMessages from '../../../i18n/globalMessages'; -import useDiscover from '../../../hooks/useDiscover'; -import Error from '../../../pages/_error'; -import { TvNetwork } from '../../../../server/models/common'; +import { defineMessages, useIntl } from 'react-intl'; const messages = defineMessages({ networkSeries: '{network} Series', }); -const DiscoverTvNetwork: React.FC = () => { +const DiscoverTvNetwork = () => { const router = useRouter(); const intl = useIntl(); diff --git a/src/components/Discover/DiscoverStudio/index.tsx b/src/components/Discover/DiscoverStudio/index.tsx index b1f3b0662..1d78748ba 100644 --- a/src/components/Discover/DiscoverStudio/index.tsx +++ b/src/components/Discover/DiscoverStudio/index.tsx @@ -1,20 +1,19 @@ -import React from 'react'; -import type { MovieResult } from '../../../../server/models/Search'; -import ListView from '../../Common/ListView'; -import { defineMessages, useIntl } from 'react-intl'; -import Header from '../../Common/Header'; -import PageTitle from '../../Common/PageTitle'; +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 globalMessages from '@app/i18n/globalMessages'; +import Error from '@app/pages/_error'; +import type { ProductionCompany } from '@server/models/common'; +import type { MovieResult } from '@server/models/Search'; import { useRouter } from 'next/router'; -import globalMessages from '../../../i18n/globalMessages'; -import useDiscover from '../../../hooks/useDiscover'; -import Error from '../../../pages/_error'; -import { ProductionCompany } from '../../../../server/models/common'; +import { defineMessages, useIntl } from 'react-intl'; const messages = defineMessages({ studioMovies: '{studio} Movies', }); -const DiscoverMovieStudio: React.FC = () => { +const DiscoverMovieStudio = () => { const router = useRouter(); const intl = useIntl(); diff --git a/src/components/Discover/DiscoverTv.tsx b/src/components/Discover/DiscoverTv.tsx index 60c292258..404b1aa5b 100644 --- a/src/components/Discover/DiscoverTv.tsx +++ b/src/components/Discover/DiscoverTv.tsx @@ -1,17 +1,16 @@ -import React from 'react'; -import type { TvResult } from '../../../server/models/Search'; -import ListView from '../Common/ListView'; +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 { TvResult } from '@server/models/Search'; import { defineMessages, useIntl } from 'react-intl'; -import Header from '../Common/Header'; -import PageTitle from '../Common/PageTitle'; -import useDiscover from '../../hooks/useDiscover'; -import Error from '../../pages/_error'; const messages = defineMessages({ discovertv: 'Popular Series', }); -const DiscoverTv: React.FC = () => { +const DiscoverTv = () => { const intl = useIntl(); const { diff --git a/src/components/Discover/DiscoverTvGenre/index.tsx b/src/components/Discover/DiscoverTvGenre/index.tsx index d4b672a5d..9602fbb8a 100644 --- a/src/components/Discover/DiscoverTvGenre/index.tsx +++ b/src/components/Discover/DiscoverTvGenre/index.tsx @@ -1,19 +1,18 @@ -import React from 'react'; -import type { TvResult } from '../../../../server/models/Search'; -import ListView from '../../Common/ListView'; -import { defineMessages, useIntl } from 'react-intl'; -import Header from '../../Common/Header'; -import PageTitle from '../../Common/PageTitle'; +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 globalMessages from '@app/i18n/globalMessages'; +import Error from '@app/pages/_error'; +import type { TvResult } from '@server/models/Search'; import { useRouter } from 'next/router'; -import globalMessages from '../../../i18n/globalMessages'; -import useDiscover from '../../../hooks/useDiscover'; -import Error from '../../../pages/_error'; +import { defineMessages, useIntl } from 'react-intl'; const messages = defineMessages({ genreSeries: '{genre} Series', }); -const DiscoverTvGenre: React.FC = () => { +const DiscoverTvGenre = () => { const router = useRouter(); const intl = useIntl(); diff --git a/src/components/Discover/DiscoverTvLanguage/index.tsx b/src/components/Discover/DiscoverTvLanguage/index.tsx index ed0873f90..b6c710e90 100644 --- a/src/components/Discover/DiscoverTvLanguage/index.tsx +++ b/src/components/Discover/DiscoverTvLanguage/index.tsx @@ -1,19 +1,18 @@ -import React from 'react'; -import type { TvResult } from '../../../../server/models/Search'; -import ListView from '../../Common/ListView'; -import { defineMessages, useIntl } from 'react-intl'; -import Header from '../../Common/Header'; -import PageTitle from '../../Common/PageTitle'; +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 globalMessages from '@app/i18n/globalMessages'; +import Error from '@app/pages/_error'; +import type { TvResult } from '@server/models/Search'; import { useRouter } from 'next/router'; -import globalMessages from '../../../i18n/globalMessages'; -import useDiscover from '../../../hooks/useDiscover'; -import Error from '../../../pages/_error'; +import { defineMessages, useIntl } from 'react-intl'; const messages = defineMessages({ languageSeries: '{language} Series', }); -const DiscoverTvLanguage: React.FC = () => { +const DiscoverTvLanguage = () => { const router = useRouter(); const intl = useIntl(); diff --git a/src/components/Discover/DiscoverTvUpcoming.tsx b/src/components/Discover/DiscoverTvUpcoming.tsx index 5b59f26a2..2a6939648 100644 --- a/src/components/Discover/DiscoverTvUpcoming.tsx +++ b/src/components/Discover/DiscoverTvUpcoming.tsx @@ -1,17 +1,16 @@ -import React from 'react'; -import type { TvResult } from '../../../server/models/Search'; -import ListView from '../Common/ListView'; +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 { TvResult } from '@server/models/Search'; import { defineMessages, useIntl } from 'react-intl'; -import Header from '../Common/Header'; -import PageTitle from '../Common/PageTitle'; -import useDiscover from '../../hooks/useDiscover'; -import Error from '../../pages/_error'; const messages = defineMessages({ upcomingtv: 'Upcoming Series', }); -const DiscoverTvUpcoming: React.FC = () => { +const DiscoverTvUpcoming = () => { const intl = useIntl(); const { diff --git a/src/components/Discover/DiscoverWatchlist/index.tsx b/src/components/Discover/DiscoverWatchlist/index.tsx new file mode 100644 index 000000000..fbbdff014 --- /dev/null +++ b/src/components/Discover/DiscoverWatchlist/index.tsx @@ -0,0 +1,84 @@ +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 { useUser } from '@app/hooks/useUser'; +import Error from '@app/pages/_error'; +import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { defineMessages, useIntl } from 'react-intl'; + +const messages = defineMessages({ + discoverwatchlist: 'Your Plex Watchlist', + watchlist: 'Plex Watchlist', +}); + +const DiscoverWatchlist = () => { + const intl = useIntl(); + const router = useRouter(); + const { user } = useUser({ + id: Number(router.query.userId), + }); + const { user: currentUser } = useUser(); + + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover( + `/api/v1/${ + router.pathname.startsWith('/profile') + ? `user/${currentUser?.id}` + : router.query.userId + ? `user/${router.query.userId}` + : 'discover' + }/watchlist` + ); + + if (error) { + return ; + } + + const title = intl.formatMessage( + router.query.userId ? messages.watchlist : messages.discoverwatchlist + ); + + return ( + <> + +
    + 0) + } + isReachingEnd={isReachingEnd} + onScrollBottom={fetchMore} + /> + + ); +}; + +export default DiscoverWatchlist; diff --git a/src/components/Discover/MovieGenreList/index.tsx b/src/components/Discover/MovieGenreList/index.tsx index bc85adad4..f19f5770f 100644 --- a/src/components/Discover/MovieGenreList/index.tsx +++ b/src/components/Discover/MovieGenreList/index.tsx @@ -1,19 +1,18 @@ -import React from 'react'; +import Header from '@app/components/Common/Header'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import { genreColorMap } from '@app/components/Discover/constants'; +import GenreCard from '@app/components/GenreCard'; +import Error from '@app/pages/_error'; +import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces'; -import Error from '../../../pages/_error'; -import Header from '../../Common/Header'; -import LoadingSpinner from '../../Common/LoadingSpinner'; -import PageTitle from '../../Common/PageTitle'; -import GenreCard from '../../GenreCard'; -import { genreColorMap } from '../constants'; const messages = defineMessages({ moviegenres: 'Movie Genres', }); -const MovieGenreList: React.FC = () => { +const MovieGenreList = () => { const intl = useIntl(); const { data, error } = useSWR( `/api/v1/discover/genreslider/movie` diff --git a/src/components/Discover/MovieGenreSlider/index.tsx b/src/components/Discover/MovieGenreSlider/index.tsx index cf1b8ce1f..4899d3496 100644 --- a/src/components/Discover/MovieGenreSlider/index.tsx +++ b/src/components/Discover/MovieGenreSlider/index.tsx @@ -1,18 +1,18 @@ +import { genreColorMap } from '@app/components/Discover/constants'; +import GenreCard from '@app/components/GenreCard'; +import Slider from '@app/components/Slider'; import { ArrowCircleRightIcon } from '@heroicons/react/outline'; +import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; import Link from 'next/link'; import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces'; -import GenreCard from '../../GenreCard'; -import Slider from '../../Slider'; -import { genreColorMap } from '../constants'; const messages = defineMessages({ moviegenres: 'Movie Genres', }); -const MovieGenreSlider: React.FC = () => { +const MovieGenreSlider = () => { const intl = useIntl(); const { data, error } = useSWR( `/api/v1/discover/genreslider/movie`, diff --git a/src/components/Discover/NetworkSlider/index.tsx b/src/components/Discover/NetworkSlider/index.tsx index 61468a6fb..8973cbd1e 100644 --- a/src/components/Discover/NetworkSlider/index.tsx +++ b/src/components/Discover/NetworkSlider/index.tsx @@ -1,7 +1,6 @@ -import React from 'react'; +import CompanyCard from '@app/components/CompanyCard'; +import Slider from '@app/components/Slider'; import { defineMessages, useIntl } from 'react-intl'; -import CompanyCard from '../../CompanyCard'; -import Slider from '../../Slider'; const messages = defineMessages({ networks: 'Networks', @@ -142,7 +141,7 @@ const networks: Network[] = [ }, ]; -const NetworkSlider: React.FC = () => { +const NetworkSlider = () => { const intl = useIntl(); return ( diff --git a/src/components/Discover/StudioSlider/index.tsx b/src/components/Discover/StudioSlider/index.tsx index 59f0e8c07..3f1361427 100644 --- a/src/components/Discover/StudioSlider/index.tsx +++ b/src/components/Discover/StudioSlider/index.tsx @@ -1,7 +1,6 @@ -import React from 'react'; +import CompanyCard from '@app/components/CompanyCard'; +import Slider from '@app/components/Slider'; import { defineMessages, useIntl } from 'react-intl'; -import CompanyCard from '../../CompanyCard'; -import Slider from '../../Slider'; const messages = defineMessages({ studios: 'Studios', @@ -21,10 +20,10 @@ const studios: Studio[] = [ url: '/discover/movies/studio/2', }, { - name: '20th Century Fox', + name: '20th Century Studios', image: - 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/qZCc1lty5FzX30aOCVRBLzaVmcp.png', - url: '/discover/movies/studio/25', + 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/h0rjX5vjW5r8yEnUBStFarjcLT4.png', + url: '/discover/movies/studio/127928', }, { name: 'Sony Pictures', @@ -76,7 +75,7 @@ const studios: Studio[] = [ }, ]; -const StudioSlider: React.FC = () => { +const StudioSlider = () => { const intl = useIntl(); return ( diff --git a/src/components/Discover/Trending.tsx b/src/components/Discover/Trending.tsx index c0f2e222d..5210e7d3c 100644 --- a/src/components/Discover/Trending.tsx +++ b/src/components/Discover/Trending.tsx @@ -1,21 +1,20 @@ -import React from 'react'; +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 { MovieResult, - TvResult, PersonResult, -} from '../../../server/models/Search'; -import ListView from '../Common/ListView'; + TvResult, +} from '@server/models/Search'; import { defineMessages, useIntl } from 'react-intl'; -import Header from '../Common/Header'; -import PageTitle from '../Common/PageTitle'; -import useDiscover from '../../hooks/useDiscover'; -import Error from '../../pages/_error'; const messages = defineMessages({ trending: 'Trending', }); -const Trending: React.FC = () => { +const Trending = () => { const intl = useIntl(); const { isLoadingInitialData, diff --git a/src/components/Discover/TvGenreList/index.tsx b/src/components/Discover/TvGenreList/index.tsx index 15fe9a017..391c51f2d 100644 --- a/src/components/Discover/TvGenreList/index.tsx +++ b/src/components/Discover/TvGenreList/index.tsx @@ -1,19 +1,18 @@ -import React from 'react'; +import Header from '@app/components/Common/Header'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import { genreColorMap } from '@app/components/Discover/constants'; +import GenreCard from '@app/components/GenreCard'; +import Error from '@app/pages/_error'; +import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces'; -import Error from '../../../pages/_error'; -import Header from '../../Common/Header'; -import LoadingSpinner from '../../Common/LoadingSpinner'; -import PageTitle from '../../Common/PageTitle'; -import GenreCard from '../../GenreCard'; -import { genreColorMap } from '../constants'; const messages = defineMessages({ seriesgenres: 'Series Genres', }); -const TvGenreList: React.FC = () => { +const TvGenreList = () => { const intl = useIntl(); const { data, error } = useSWR( `/api/v1/discover/genreslider/tv` diff --git a/src/components/Discover/TvGenreSlider/index.tsx b/src/components/Discover/TvGenreSlider/index.tsx index 54f8daa34..820012c32 100644 --- a/src/components/Discover/TvGenreSlider/index.tsx +++ b/src/components/Discover/TvGenreSlider/index.tsx @@ -1,18 +1,18 @@ +import { genreColorMap } from '@app/components/Discover/constants'; +import GenreCard from '@app/components/GenreCard'; +import Slider from '@app/components/Slider'; import { ArrowCircleRightIcon } from '@heroicons/react/outline'; +import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; import Link from 'next/link'; import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces'; -import GenreCard from '../../GenreCard'; -import Slider from '../../Slider'; -import { genreColorMap } from '../constants'; const messages = defineMessages({ tvgenres: 'Series Genres', }); -const TvGenreSlider: React.FC = () => { +const TvGenreSlider = () => { const intl = useIntl(); const { data, error } = useSWR( `/api/v1/discover/genreslider/tv`, diff --git a/src/components/Discover/Upcoming.tsx b/src/components/Discover/Upcoming.tsx index 1e14f73dc..b556e6f9d 100644 --- a/src/components/Discover/Upcoming.tsx +++ b/src/components/Discover/Upcoming.tsx @@ -1,17 +1,16 @@ -import React from 'react'; -import type { MovieResult } from '../../../server/models/Search'; -import ListView from '../Common/ListView'; +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 { MovieResult } from '@server/models/Search'; import { defineMessages, useIntl } from 'react-intl'; -import Header from '../Common/Header'; -import PageTitle from '../Common/PageTitle'; -import useDiscover from '../../hooks/useDiscover'; -import Error from '../../pages/_error'; const messages = defineMessages({ upcomingmovies: 'Upcoming Movies', }); -const UpcomingMovies: React.FC = () => { +const UpcomingMovies = () => { const intl = useIntl(); const { diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index 3ebd6226c..24dc6fea5 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -1,19 +1,20 @@ +import PageTitle from '@app/components/Common/PageTitle'; +import MovieGenreSlider from '@app/components/Discover/MovieGenreSlider'; +import NetworkSlider from '@app/components/Discover/NetworkSlider'; +import StudioSlider from '@app/components/Discover/StudioSlider'; +import TvGenreSlider from '@app/components/Discover/TvGenreSlider'; +import MediaSlider from '@app/components/MediaSlider'; +import RequestCard from '@app/components/RequestCard'; +import Slider from '@app/components/Slider'; +import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard'; +import { Permission, UserType, useUser } from '@app/hooks/useUser'; import { ArrowCircleRightIcon } from '@heroicons/react/outline'; +import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces'; +import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces'; +import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces'; import Link from 'next/link'; -import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import type { MediaResultsResponse } from '../../../server/interfaces/api/mediaInterfaces'; -import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces'; -import PageTitle from '../Common/PageTitle'; -import MediaSlider from '../MediaSlider'; -import RequestCard from '../RequestCard'; -import Slider from '../Slider'; -import TmdbTitleCard from '../TitleCard/TmdbTitleCard'; -import MovieGenreSlider from './MovieGenreSlider'; -import NetworkSlider from './NetworkSlider'; -import StudioSlider from './StudioSlider'; -import TvGenreSlider from './TvGenreSlider'; const messages = defineMessages({ discover: 'Discover', @@ -22,13 +23,16 @@ const messages = defineMessages({ populartv: 'Popular Series', upcomingtv: 'Upcoming Series', recentlyAdded: 'Recently Added', - noRequests: 'No requests.', upcoming: 'Upcoming Movies', trending: 'Trending', + plexwatchlist: 'Your Plex Watchlist', + emptywatchlist: + 'Media added to your Plex Watchlist will appear here.', }); -const Discover: React.FC = () => { +const Discover = () => { const intl = useIntl(); + const { user, hasPermission } = useUser(); const { data: media, error: mediaError } = useSWR( '/api/v1/media?filter=allavailable&take=20&sort=mediaAdded', @@ -38,50 +42,114 @@ const Discover: React.FC = () => { const { data: requests, error: requestError } = useSWR( '/api/v1/request?filter=all&take=10&sort=modified&skip=0', - { revalidateOnMount: true } + { + revalidateOnMount: true, + } ); + const { data: watchlistItems, error: watchlistError } = useSWR<{ + page: number; + totalPages: number; + totalResults: number; + results: WatchlistItem[]; + }>(user?.userType === UserType.PLEX ? '/api/v1/discover/watchlist' : null, { + revalidateOnMount: true, + }); + return ( <> -
    -
    - {intl.formatMessage(messages.recentlyAdded)} -
    -
    - ( - - ))} - /> -
    - ( - +
    +
    + {intl.formatMessage(messages.recentlyAdded)} +
    +
    + ( + + ))} + /> + + )} + {(!requests || !!requests.results.length) && !requestError && ( + <> + + ( + + ))} + placeholder={} /> - ))} - placeholder={} - emptyMessage={intl.formatMessage(messages.noRequests)} - /> + + )} + {user?.userType === UserType.PLEX && + (!watchlistItems || + !!watchlistItems.results.length || + user.settings?.watchlistSyncMovies || + user.settings?.watchlistSyncTv) && + !watchlistError && ( + <> + + ( + + {msg} + + ), + })} + items={watchlistItems?.results.map((item) => ( + + ))} + /> + + )} = ({ - downloadItem, - is4k = false, -}) => { +const DownloadBlock = ({ downloadItem, is4k = false }: DownloadBlockProps) => { const intl = useIntl(); return ( diff --git a/src/components/ExternalLinkBlock/index.tsx b/src/components/ExternalLinkBlock/index.tsx index af49fda99..d7c4b4602 100644 --- a/src/components/ExternalLinkBlock/index.tsx +++ b/src/components/ExternalLinkBlock/index.tsx @@ -1,15 +1,14 @@ -import React from 'react'; -import { MediaType } from '../../../server/constants/media'; -import { MediaServerType } from '../../../server/constants/server'; -import ImdbLogo from '../../assets/services/imdb.svg'; -import JellyfinLogo from '../../assets/services/jellyfin.svg'; -import PlexLogo from '../../assets/services/plex.svg'; -import RTLogo from '../../assets/services/rt.svg'; -import TmdbLogo from '../../assets/services/tmdb.svg'; -import TraktLogo from '../../assets/services/trakt.svg'; -import TvdbLogo from '../../assets/services/tvdb.svg'; -import useLocale from '../../hooks/useLocale'; -import useSettings from '../../hooks/useSettings'; +import ImdbLogo from '@app/assets/services/imdb.svg'; +import JellyfinLogo from '@app/assets/services/jellyfin.svg'; +import PlexLogo from '@app/assets/services/plex.svg'; +import RTLogo from '@app/assets/services/rt.svg'; +import TmdbLogo from '@app/assets/services/tmdb.svg'; +import TraktLogo from '@app/assets/services/trakt.svg'; +import TvdbLogo from '@app/assets/services/tvdb.svg'; +import useLocale from '@app/hooks/useLocale'; +import useSettings from '@app/hooks/useSettings'; +import { MediaType } from '@server/constants/media'; +import { MediaServerType } from '@server/constants/server'; interface ExternalLinkBlockProps { mediaType: 'movie' | 'tv'; @@ -20,14 +19,14 @@ interface ExternalLinkBlockProps { mediaUrl?: string; } -const ExternalLinkBlock: React.FC = ({ +const ExternalLinkBlock = ({ mediaType, tmdbId, tvdbId, imdbId, rtUrl, mediaUrl, -}) => { +}: ExternalLinkBlockProps) => { const settings = useSettings(); const { locale } = useLocale(); @@ -79,7 +78,7 @@ const ExternalLinkBlock: React.FC = ({ )} {rtUrl && ( = ({ - image, - url, - name, - canExpand = false, -}) => { +const GenreCard = ({ image, url, name, canExpand = false }: GenreCardProps) => { const [isHovered, setHovered] = useState(false); return ( @@ -54,7 +49,7 @@ const GenreCard: React.FC = ({ ); }; -const GenreCardPlaceholder: React.FC = () => { +const GenreCardPlaceholder = () => { return (
    = ({ issue }) => { +const IssueBlock = ({ issue }: IssueBlockProps) => { const { user } = useUser(); const intl = useIntl(); const issueOption = issueOptions.find( diff --git a/src/components/IssueDetails/IssueComment/index.tsx b/src/components/IssueDetails/IssueComment/index.tsx index 11623f53d..b941c9f31 100644 --- a/src/components/IssueDetails/IssueComment/index.tsx +++ b/src/components/IssueDetails/IssueComment/index.tsx @@ -1,18 +1,16 @@ -import { Menu } from '@headlessui/react'; -import { ExclamationIcon } from '@heroicons/react/outline'; +import Button from '@app/components/Common/Button'; +import Modal from '@app/components/Common/Modal'; +import { Permission, useUser } from '@app/hooks/useUser'; +import { Menu, Transition } from '@headlessui/react'; import { DotsVerticalIcon } from '@heroicons/react/solid'; +import type { default as IssueCommentType } from '@server/entity/IssueComment'; import axios from 'axios'; import { Field, Form, Formik } from 'formik'; import Link from 'next/link'; -import React, { useState } from 'react'; +import { Fragment, useState } from 'react'; import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; import ReactMarkdown from 'react-markdown'; import * as Yup from 'yup'; -import type { default as IssueCommentType } from '../../../../server/entity/IssueComment'; -import { Permission, useUser } from '../../../hooks/useUser'; -import Button from '../../Common/Button'; -import Modal from '../../Common/Modal'; -import Transition from '../../Transition'; const messages = defineMessages({ postedby: 'Posted {relativeTime} by {username}', @@ -30,12 +28,12 @@ interface IssueCommentProps { onUpdate?: () => void; } -const IssueComment: React.FC = ({ +const IssueComment = ({ comment, isReversed = false, isActiveUser = false, onUpdate, -}) => { +}: IssueCommentProps) => { const intl = useIntl(); const [showDeleteModal, setShowDeleteModal] = useState(false); const [isEditing, setIsEditing] = useState(false); @@ -66,6 +64,7 @@ const IssueComment: React.FC = ({ } mt-4 space-x-4`} > = ({ onOk={() => deleteComment()} okText={intl.formatMessage(messages.delete)} okButtonType="danger" - iconSvg={} > {intl.formatMessage(messages.areyousuredelete)} @@ -114,6 +112,7 @@ const IssueComment: React.FC = ({
    = ({ name="newMessage" className="h-24" /> - {errors.newMessage && touched.newMessage && ( -
    {errors.newMessage}
    - )} + {errors.newMessage && + touched.newMessage && + typeof errors.newMessage === 'string' && ( +
    {errors.newMessage}
    + )}
    diff --git a/src/components/IssueModal/CreateIssueModal/index.tsx b/src/components/IssueModal/CreateIssueModal/index.tsx index 5dbc41802..0ed4162f8 100644 --- a/src/components/IssueModal/CreateIssueModal/index.tsx +++ b/src/components/IssueModal/CreateIssueModal/index.tsx @@ -1,28 +1,25 @@ +import Button from '@app/components/Common/Button'; +import Modal from '@app/components/Common/Modal'; +import { issueOptions } from '@app/components/IssueModal/constants'; +import useSettings from '@app/hooks/useSettings'; +import { Permission, useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; import { RadioGroup } from '@headlessui/react'; -import { ExclamationIcon } from '@heroicons/react/outline'; import { ArrowCircleRightIcon } from '@heroicons/react/solid'; +import { MediaStatus } from '@server/constants/media'; +import type Issue from '@server/entity/Issue'; +import type { MovieDetails } from '@server/models/Movie'; +import type { TvDetails } from '@server/models/Tv'; import axios from 'axios'; import { Field, Formik } from 'formik'; import Link from 'next/link'; -import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; import * as Yup from 'yup'; -import { MediaStatus } from '../../../../server/constants/media'; -import type Issue from '../../../../server/entity/Issue'; -import { MovieDetails } from '../../../../server/models/Movie'; -import { TvDetails } from '../../../../server/models/Tv'; -import useSettings from '../../../hooks/useSettings'; -import { Permission, useUser } from '../../../hooks/useUser'; -import globalMessages from '../../../i18n/globalMessages'; -import Button from '../../Common/Button'; -import Modal from '../../Common/Modal'; -import { issueOptions } from '../constants'; const messages = defineMessages({ validationMessageRequired: 'You must provide a description', - issomethingwrong: 'Is there a problem with {title}?', whatswrong: "What's wrong?", providedetail: 'Please provide a detailed explanation of the issue you encountered.', @@ -55,11 +52,11 @@ interface CreateIssueModalProps { onCancel?: () => void; } -const CreateIssueModal: React.FC = ({ +const CreateIssueModal = ({ onCancel, mediaType, tmdbId, -}) => { +}: CreateIssueModalProps) => { const intl = useIntl(); const settings = useSettings(); const { hasPermission } = useUser(); @@ -118,9 +115,7 @@ const CreateIssueModal: React.FC = ({
    {intl.formatMessage(messages.toastSuccessCreate, { title: isMovie(data) ? data.title : data.name, - strong: function strong(msg) { - return {msg}; - }, + strong: (msg: React.ReactNode) => {msg}, })}
    @@ -153,23 +148,14 @@ const CreateIssueModal: React.FC = ({ } title={intl.formatMessage(messages.reportissue)} + subTitle={data && isMovie(data) ? data?.title : data?.name} cancelText={intl.formatMessage(globalMessages.close)} onOk={() => handleSubmit()} okText={intl.formatMessage(messages.submitissue)} loading={!data && !error} backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`} > - {data && ( -
    - - {intl.formatMessage(messages.issomethingwrong, { - title: isMovie(data) ? data.title : data.name, - })} - -
    - )} {mediaType === 'tv' && data && !isMovie(data) && ( <>
    @@ -267,7 +253,7 @@ const CreateIssueModal: React.FC = ({ ? 'rounded-bl-md rounded-br-md' : '', checked - ? 'z-10 border-indigo-500 bg-indigo-600' + ? 'z-10 border border-indigo-500 bg-indigo-400 bg-opacity-20' : 'border-gray-500', 'relative flex cursor-pointer border p-4 focus:outline-none' ) @@ -278,7 +264,7 @@ const CreateIssueModal: React.FC = ({ = ({ className="h-28" placeholder={intl.formatMessage(messages.providedetail)} /> - {errors.message && touched.message && ( -
    {errors.message}
    - )} + {errors.message && + touched.message && + typeof errors.message === 'string' && ( +
    {errors.message}
    + )}
    ); diff --git a/src/components/IssueModal/constants.ts b/src/components/IssueModal/constants.ts index 92cf6bc77..7552c6333 100644 --- a/src/components/IssueModal/constants.ts +++ b/src/components/IssueModal/constants.ts @@ -1,5 +1,6 @@ -import { defineMessages, MessageDescriptor } from 'react-intl'; -import { IssueType } from '../../../server/constants/issue'; +import { IssueType } from '@server/constants/issue'; +import type { MessageDescriptor } from 'react-intl'; +import { defineMessages } from 'react-intl'; const messages = defineMessages({ issueAudio: 'Audio', diff --git a/src/components/IssueModal/index.tsx b/src/components/IssueModal/index.tsx index f3f226de9..6ec67c245 100644 --- a/src/components/IssueModal/index.tsx +++ b/src/components/IssueModal/index.tsx @@ -1,6 +1,5 @@ -import React from 'react'; -import Transition from '../Transition'; -import CreateIssueModal from './CreateIssueModal'; +import CreateIssueModal from '@app/components/IssueModal/CreateIssueModal'; +import { Transition } from '@headlessui/react'; interface IssueModalProps { show?: boolean; @@ -10,13 +9,9 @@ interface IssueModalProps { issueId?: never; } -const IssueModal: React.FC = ({ - show, - mediaType, - onCancel, - tmdbId, -}) => ( +const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => ( { name: string; value: string; onUpdate: (value: string) => void; } -const JSONEditor: React.FC = ({ - name, - value, - onUpdate, - onBlur, -}) => { +const JSONEditor = ({ name, value, onUpdate, onBlur }: JSONEditorProps) => { return (
    = ({ +const LanguageSelector = ({ value, setFieldValue, serverValue, isUserSettings = false, -}) => { +}: LanguageSelectorProps) => { const intl = useIntl(); const { data: languages } = useSWR('/api/v1/languages'); diff --git a/src/components/Layout/LanguagePicker/index.tsx b/src/components/Layout/LanguagePicker/index.tsx index 1d610604f..0eec6b7d3 100644 --- a/src/components/Layout/LanguagePicker/index.tsx +++ b/src/components/Layout/LanguagePicker/index.tsx @@ -1,19 +1,17 @@ +import type { AvailableLocale } from '@app/context/LanguageContext'; +import { availableLanguages } from '@app/context/LanguageContext'; +import useClickOutside from '@app/hooks/useClickOutside'; +import useLocale from '@app/hooks/useLocale'; +import { Transition } from '@headlessui/react'; import { TranslateIcon } from '@heroicons/react/solid'; -import React, { useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { - availableLanguages, - AvailableLocale, -} from '../../../context/LanguageContext'; -import useClickOutside from '../../../hooks/useClickOutside'; -import useLocale from '../../../hooks/useLocale'; -import Transition from '../../Transition'; const messages = defineMessages({ displaylanguage: 'Display Language', }); -const LanguagePicker: React.FC = () => { +const LanguagePicker = () => { const intl = useIntl(); const dropdownRef = useRef(null); const { locale, setLocale } = useLocale(); @@ -34,6 +32,7 @@ const LanguagePicker: React.FC = () => {
    { +const Notifications = () => { return (
    @@ -168,6 +173,7 @@ const Sidebar: React.FC = ({ open, setClosed }) => { : 'hover:bg-gray-700 focus:bg-gray-700' } `} + data-testid={`${sidebarLink.dataTestId}-mobile`} > {sidebarLink.svgIcon} {intl.formatMessage( @@ -193,7 +199,7 @@ const Sidebar: React.FC = ({ open, setClosed }) => { {/* */}
    - +
    @@ -233,6 +239,7 @@ const Sidebar: React.FC = ({ open, setClosed }) => { : 'hover:bg-gray-700 focus:bg-gray-700' } `} + data-testid={sidebarLink.dataTestId} > {sidebarLink.svgIcon} {intl.formatMessage(messages[sidebarLink.messagesKey])} diff --git a/src/components/Layout/UserDropdown/MiniQuotaDisplay/index.tsx b/src/components/Layout/UserDropdown/MiniQuotaDisplay/index.tsx new file mode 100644 index 000000000..abc08dd18 --- /dev/null +++ b/src/components/Layout/UserDropdown/MiniQuotaDisplay/index.tsx @@ -0,0 +1,93 @@ +import Infinity from '@app/assets/infinity.svg'; +import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner'; +import ProgressCircle from '@app/components/Common/ProgressCircle'; +import type { QuotaResponse } from '@server/interfaces/api/userInterfaces'; +import { defineMessages, useIntl } from 'react-intl'; +import useSWR from 'swr'; + +const messages = defineMessages({ + movierequests: 'Movie Requests', + seriesrequests: 'Series Requests', +}); + +type MiniQuotaDisplayProps = { + userId: number; +}; + +const MiniQuotaDisplay = ({ userId }: MiniQuotaDisplayProps) => { + const intl = useIntl(); + const { data, error } = useSWR(`/api/v1/user/${userId}/quota`); + + if (error) { + return null; + } + + if (!data && !error) { + return ; + } + + return ( + <> + {((data?.movie.limit ?? 0) !== 0 || (data?.tv.limit ?? 0) !== 0) && ( +
    +
    +
    + {intl.formatMessage(messages.movierequests)} +
    +
    + {data?.movie.limit ?? 0 > 0 ? ( + <> + + + {data?.movie.remaining} / {data?.movie.limit} + + + ) : ( + <> + + Unlimited + + )} +
    +
    +
    +
    + {intl.formatMessage(messages.seriesrequests)} +
    +
    + {data?.tv.limit ?? 0 > 0 ? ( + <> + + + {data?.tv.remaining} / {data?.tv.limit} + + + ) : ( + <> + + Unlimited + + )} +
    +
    +
    + )} + + ); +}; + +export default MiniQuotaDisplay; diff --git a/src/components/Layout/UserDropdown/index.tsx b/src/components/Layout/UserDropdown/index.tsx index e51fcabf5..57481b719 100644 --- a/src/components/Layout/UserDropdown/index.tsx +++ b/src/components/Layout/UserDropdown/index.tsx @@ -1,25 +1,39 @@ -import { LogoutIcon } from '@heroicons/react/outline'; +import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay'; +import { useUser } from '@app/hooks/useUser'; +import { Menu, Transition } from '@headlessui/react'; +import { ClockIcon, LogoutIcon } from '@heroicons/react/outline'; import { CogIcon, UserIcon } from '@heroicons/react/solid'; import axios from 'axios'; +import type { LinkProps } from 'next/link'; import Link from 'next/link'; -import React, { useRef, useState } from 'react'; +import { forwardRef, Fragment } from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import useClickOutside from '../../../hooks/useClickOutside'; -import { useUser } from '../../../hooks/useUser'; -import Transition from '../../Transition'; const messages = defineMessages({ myprofile: 'Profile', settings: 'Settings', + requests: 'Requests', signout: 'Sign Out', }); -const UserDropdown: React.FC = () => { +const ForwardedLink = forwardRef< + HTMLAnchorElement, + LinkProps & React.ComponentPropsWithoutRef<'a'> +>(({ href, children, ...rest }, ref) => { + return ( + +
    + {children} + + + ); +}); + +ForwardedLink.displayName = 'ForwardedLink'; + +const UserDropdown = () => { const intl = useIntl(); - const dropdownRef = useRef(null); const { user, revalidate } = useUser(); - const [isDropdownOpen, setDropdownOpen] = useState(false); - useClickOutside(dropdownRef, () => setDropdownOpen(false)); const logout = async () => { const response = await axios.post('/api/v1/auth/logout'); @@ -30,86 +44,119 @@ const UserDropdown: React.FC = () => { }; return ( -
    +
    - +
    -
    -
    - - { - if (e.key === 'Enter') { - setDropdownOpen(false); - } - }} - onClick={() => setDropdownOpen(false)} - > - - {intl.formatMessage(messages.myprofile)} - - - - { - if (e.key === 'Enter') { - setDropdownOpen(false); - } - }} - onClick={() => setDropdownOpen(false)} - > - - {intl.formatMessage(messages.settings)} - - - logout()} - > - - {intl.formatMessage(messages.signout)} - + +
    +
    +
    + +
    + + {user?.displayName} + + + {user?.email} + +
    +
    + {user && } +
    +
    + + {({ active }) => ( + + + {intl.formatMessage(messages.myprofile)} + + )} + + + {({ active }) => ( + + + {intl.formatMessage(messages.requests)} + + )} + + + {({ active }) => ( + + + {intl.formatMessage(messages.settings)} + + )} + + + {({ active }) => ( + logout()} + > + + {intl.formatMessage(messages.signout)} + + )} + +
    -
    + -
    +
    ); }; diff --git a/src/components/Layout/UserWarnings/index.tsx b/src/components/Layout/UserWarnings/index.tsx index fe621d2a9..ec32ecc6b 100644 --- a/src/components/Layout/UserWarnings/index.tsx +++ b/src/components/Layout/UserWarnings/index.tsx @@ -1,8 +1,8 @@ -import React from 'react'; -import Link from 'next/link'; +import { useUser } from '@app/hooks/useUser'; import { ExclamationIcon } from '@heroicons/react/outline'; +import Link from 'next/link'; +import type React from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { useUser } from '../../../hooks/useUser'; const messages = defineMessages({ emailRequired: 'An email address is required.', diff --git a/src/components/Layout/VersionStatus/index.tsx b/src/components/Layout/VersionStatus/index.tsx index d682df4ca..515ff20e9 100644 --- a/src/components/Layout/VersionStatus/index.tsx +++ b/src/components/Layout/VersionStatus/index.tsx @@ -4,11 +4,10 @@ import { CodeIcon, ServerIcon, } from '@heroicons/react/outline'; +import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces'; import Link from 'next/link'; -import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import { StatusResponse } from '../../../../server/interfaces/api/settingsInterfaces'; const messages = defineMessages({ streamdevelop: 'Overseerr Develop', @@ -22,7 +21,7 @@ interface VersionStatusProps { onClick?: () => void; } -const VersionStatus: React.FC = ({ onClick }) => { +const VersionStatus = ({ onClick }: VersionStatusProps) => { const intl = useIntl(); const { data } = useSWR('/api/v1/status', { refreshInterval: 60 * 1000, diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index b560c66e8..2e63441ef 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -1,16 +1,21 @@ +import SearchInput from '@app/components/Layout/SearchInput'; +import Sidebar from '@app/components/Layout/Sidebar'; +import UserDropdown from '@app/components/Layout/UserDropdown'; +import PullToRefresh from '@app/components/PullToRefresh'; +import type { AvailableLocale } from '@app/context/LanguageContext'; +import useLocale from '@app/hooks/useLocale'; +import useSettings from '@app/hooks/useSettings'; +import { useUser } from '@app/hooks/useUser'; import { MenuAlt2Icon } from '@heroicons/react/outline'; import { ArrowLeftIcon } from '@heroicons/react/solid'; import { useRouter } from 'next/router'; -import React, { useEffect, useState } from 'react'; -import { AvailableLocale } from '../../context/LanguageContext'; -import useLocale from '../../hooks/useLocale'; -import useSettings from '../../hooks/useSettings'; -import { useUser } from '../../hooks/useUser'; -import SearchInput from './SearchInput'; -import Sidebar from './Sidebar'; -import UserDropdown from './UserDropdown'; +import { useEffect, useState } from 'react'; -const Layout: React.FC = ({ children }) => { +type LayoutProps = { + children: React.ReactNode; +}; + +const Layout = ({ children }: LayoutProps) => { const [isSidebarOpen, setSidebarOpen] = useState(false); const [isScrolled, setIsScrolled] = useState(false); const { user } = useUser(); @@ -54,6 +59,7 @@ const Layout: React.FC = ({ children }) => { setSidebarOpen(false)} />
    +
    { } transition duration-300 focus:outline-none lg:hidden`} aria-label="Open sidebar" onClick={() => setSidebarOpen(true)} + data-testid="sidebar-toggle" > diff --git a/src/components/LoadingBar/index.tsx b/src/components/LoadingBar/index.tsx index 712ba4db0..1e488c671 100644 --- a/src/components/LoadingBar/index.tsx +++ b/src/components/LoadingBar/index.tsx @@ -1,7 +1,7 @@ import { NProgress } from '@tanem/react-nprogress'; +import { useRouter } from 'next/router'; import React, { useEffect, useState } from 'react'; import ReactDOM from 'react-dom'; -import { useRouter } from 'next/router'; interface BarProps { progress: number; diff --git a/src/components/Login/AddEmailModal.tsx b/src/components/Login/AddEmailModal.tsx index 4cd271b93..01bc81234 100644 --- a/src/components/Login/AddEmailModal.tsx +++ b/src/components/Login/AddEmailModal.tsx @@ -1,11 +1,11 @@ -import React from 'react'; -import Transition from '../Transition'; -import Modal from '../Common/Modal'; -import { Formik, Field } from 'formik'; -import * as Yup from 'yup'; +import Modal from '@app/components/Common/Modal'; +import useSettings from '@app/hooks/useSettings'; +import { Transition } from '@headlessui/react'; import axios from 'axios'; +import { Field, Formik } from 'formik'; +import type React from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import useSettings from '../../hooks/useSettings'; +import * as Yup from 'yup'; const messages = defineMessages({ title: 'Add Email', diff --git a/src/components/Login/JellyfinLogin.tsx b/src/components/Login/JellyfinLogin.tsx index e76a16502..5bd38301e 100644 --- a/src/components/Login/JellyfinLogin.tsx +++ b/src/components/Login/JellyfinLogin.tsx @@ -1,12 +1,12 @@ +import Button from '@app/components/Common/Button'; +import useSettings from '@app/hooks/useSettings'; import axios from 'axios'; import { Field, Form, Formik } from 'formik'; -import React from 'react'; +import getConfig from 'next/config'; +import type React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import * as Yup from 'yup'; -import useSettings from '../../hooks/useSettings'; -import Button from '../Common/Button'; -import getConfig from 'next/config'; const messages = defineMessages({ username: 'Username', diff --git a/src/components/Login/LocalLogin.tsx b/src/components/Login/LocalLogin.tsx index b97ac6bd5..fae793d13 100644 --- a/src/components/Login/LocalLogin.tsx +++ b/src/components/Login/LocalLogin.tsx @@ -1,13 +1,13 @@ +import Button from '@app/components/Common/Button'; +import SensitiveInput from '@app/components/Common/SensitiveInput'; +import useSettings from '@app/hooks/useSettings'; import { LoginIcon, SupportIcon } from '@heroicons/react/outline'; import axios from 'axios'; import { Field, Form, Formik } from 'formik'; import Link from 'next/link'; -import React, { useState } from 'react'; +import { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import * as Yup from 'yup'; -import useSettings from '../../hooks/useSettings'; -import Button from '../Common/Button'; -import SensitiveInput from '../Common/SensitiveInput'; const messages = defineMessages({ username: 'Username', @@ -25,7 +25,7 @@ interface LocalLoginProps { revalidate: () => void; } -const LocalLogin: React.FC = ({ revalidate }) => { +const LocalLogin = ({ revalidate }: LocalLoginProps) => { const intl = useIntl(); const settings = useSettings(); const [loginError, setLoginError] = useState(null); @@ -80,11 +80,14 @@ const LocalLogin: React.FC = ({ revalidate }) => { name="email" type="text" inputMode="email" + data-testid="email" />
    - {errors.email && touched.email && ( -
    {errors.email}
    - )} + {errors.email && + touched.email && + typeof errors.email === 'string' && ( +
    {errors.email}
    + )}
    - {errors.password && touched.password && ( -
    {errors.password}
    - )} + {errors.password && + touched.password && + typeof errors.password === 'string' && ( +
    {errors.password}
    + )}
    {loginError && (
    @@ -116,6 +122,7 @@ const LocalLogin: React.FC = ({ revalidate }) => { buttonType="primary" type="submit" disabled={isSubmitting || !isValid} + data-testid="local-signin-button" > diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index eb8f368bd..3c16bdcf6 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -1,21 +1,21 @@ +import Accordion from '@app/components/Common/Accordion'; +import ImageFader from '@app/components/Common/ImageFader'; +import PageTitle from '@app/components/Common/PageTitle'; +import LanguagePicker from '@app/components/Layout/LanguagePicker'; +import LocalLogin from '@app/components/Login/LocalLogin'; +import PlexLoginButton from '@app/components/PlexLoginButton'; +import useSettings from '@app/hooks/useSettings'; +import { useUser } from '@app/hooks/useUser'; +import { Transition } from '@headlessui/react'; import { XCircleIcon } from '@heroicons/react/solid'; +import { MediaServerType } from '@server/constants/server'; import axios from 'axios'; +import getConfig from 'next/config'; import { useRouter } from 'next/dist/client/router'; -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import { MediaServerType } from '../../../server/constants/server'; -import useSettings from '../../hooks/useSettings'; -import { useUser } from '../../hooks/useUser'; -import Accordion from '../Common/Accordion'; -import ImageFader from '../Common/ImageFader'; -import PageTitle from '../Common/PageTitle'; -import LanguagePicker from '../Layout/LanguagePicker'; -import PlexLoginButton from '../PlexLoginButton'; -import Transition from '../Transition'; import JellyfinLogin from './JellyfinLogin'; -import LocalLogin from './LocalLogin'; -import getConfig from 'next/config'; const messages = defineMessages({ signin: 'Sign In', @@ -25,7 +25,7 @@ const messages = defineMessages({ signinwithoverseerr: 'Use your {applicationTitle} account', }); -const Login: React.FC = () => { +const Login = () => { const intl = useIntl(); const [error, setError] = useState(''); const [isProcessing, setProcessing] = useState(false); @@ -78,7 +78,7 @@ const Login: React.FC = () => { `https://www.themoviedb.org/t/p/original${backdrop}` + (backdrop) => `https://image.tmdb.org/t/p/original${backdrop}` ) ?? [] } /> @@ -98,6 +98,7 @@ const Login: React.FC = () => { > <> = ({ show, mediaType, onClose, data, revalidate }) => { +const ManageSlideOver = ({ + show, + mediaType, + onClose, + data, + revalidate, +}: ManageSlideOverMovieProps | ManageSlideOverTvProps) => { const { user: currentUser, hasPermission } = useUser(); const intl = useIntl(); const settings = useSettings(); const { data: watchData } = useSWR( - data.mediaInfo && hasPermission(Permission.ADMIN) + settings.currentSettings.mediaServerType === MediaServerType.PLEX && + data.mediaInfo && + hasPermission(Permission.ADMIN) ? `/api/v1/media/${data.mediaInfo.id}/watch_data` : null ); @@ -115,9 +118,9 @@ const ManageSlideOver: React.FC< <> {intl.formatMessage(messages.plays, { playCount, - strong: function strong(msg) { - return {msg}; - }, + strong: (msg: React.ReactNode) => ( + {msg} + ), })} ); @@ -141,7 +144,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 })} -
        -
        - {styledPlayCount(watchData.data.playCount7Days)} + {!!watchData?.data && ( +
        +
        +
        +
        + {intl.formatMessage(messages.pastdays, { + days: 7, + })} +
        +
        + {styledPlayCount(watchData.data.playCount7Days)} +
        -
        -
        -
        - {intl.formatMessage(messages.pastdays, { - days: 30, - })} +
        +
        + {intl.formatMessage(messages.pastdays, { + days: 30, + })} +
        +
        + {styledPlayCount(watchData.data.playCount30Days)} +
        -
        - {styledPlayCount(watchData.data.playCount30Days)} +
        +
        + {intl.formatMessage(messages.alltime)} +
        +
        + {styledPlayCount(watchData.data.playCount)} +
        -
        -
        - {intl.formatMessage(messages.alltime)} + {!!watchData.data.users.length && ( +
        + + {intl.formatMessage(messages.playedby)} + + + {watchData.data.users.map((user) => ( + + + {user.displayName} + + + ))} +
        -
        - {styledPlayCount(watchData.data.playCount)} -
        -
        + )}
        - {!!watchData.data.users.length && ( -
        - - {intl.formatMessage(messages.playedby)} - - - {watchData.data.users.map((user) => ( - - - {user.displayName} - - - ))} - -
        - )} -
        + )} {data.mediaInfo?.tautulliUrl && ( @@ -302,7 +309,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 })} -
        -
        - {styledPlayCount(watchData.data4k.playCount7Days)} + {watchData?.data4k && ( +
        +
        +
        +
        + {intl.formatMessage(messages.pastdays, { + days: 7, + })} +
        +
        + {styledPlayCount(watchData.data4k.playCount7Days)} +
        -
        -
        - + )} {data.mediaInfo?.tautulliUrl4k && ( @@ -487,7 +500,7 @@ const ManageSlideOver: React.FC< {intl.formatMessage(messages.manageModalClearMedia)} -
        +
        {intl.formatMessage(messages.manageModalClearMediaWarning, { mediaType: intl.formatMessage( mediaType === 'movie' ? messages.movie : messages.tvshow diff --git a/src/components/MediaSlider/ShowMoreCard/index.tsx b/src/components/MediaSlider/ShowMoreCard/index.tsx index f6bc2ccb4..99900ac9a 100644 --- a/src/components/MediaSlider/ShowMoreCard/index.tsx +++ b/src/components/MediaSlider/ShowMoreCard/index.tsx @@ -1,6 +1,6 @@ import { ArrowCircleRightIcon } from '@heroicons/react/solid'; import Link from 'next/link'; -import React, { useState } from 'react'; +import { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; const messages = defineMessages({ @@ -12,7 +12,7 @@ interface ShowMoreCardProps { posters: (string | undefined)[]; } -const ShowMoreCard: React.FC = ({ url, posters }) => { +const ShowMoreCard = ({ url, posters }: ShowMoreCardProps) => { const intl = useIntl(); const [isHovered, setHovered] = useState(false); return ( diff --git a/src/components/MediaSlider/index.tsx b/src/components/MediaSlider/index.tsx index 84c72822f..9a9bc054c 100644 --- a/src/components/MediaSlider/index.tsx +++ b/src/components/MediaSlider/index.tsx @@ -1,18 +1,18 @@ +import ShowMoreCard from '@app/components/MediaSlider/ShowMoreCard'; +import PersonCard from '@app/components/PersonCard'; +import Slider from '@app/components/Slider'; +import TitleCard from '@app/components/TitleCard'; +import useSettings from '@app/hooks/useSettings'; import { ArrowCircleRightIcon } from '@heroicons/react/outline'; -import Link from 'next/link'; -import React, { useEffect } from 'react'; -import useSWRInfinite from 'swr/infinite'; -import { MediaStatus } from '../../../server/constants/media'; +import { MediaStatus } from '@server/constants/media'; import type { MovieResult, PersonResult, TvResult, -} from '../../../server/models/Search'; -import useSettings from '../../hooks/useSettings'; -import PersonCard from '../PersonCard'; -import Slider from '../Slider'; -import TitleCard from '../TitleCard'; -import ShowMoreCard from './ShowMoreCard'; +} from '@server/models/Search'; +import Link from 'next/link'; +import { useEffect } from 'react'; +import useSWRInfinite from 'swr/infinite'; interface MixedResult { page: number; @@ -29,13 +29,13 @@ interface MediaSliderProps { hideWhenEmpty?: boolean; } -const MediaSlider: React.FC = ({ +const MediaSlider = ({ title, url, linkUrl, sliderKey, hideWhenEmpty = false, -}) => { +}: MediaSliderProps) => { const settings = useSettings(); const { data, error, setSize, size } = useSWRInfinite( (pageIndex: number, previousPageData: MixedResult | null) => { diff --git a/src/components/MovieDetails/MovieCast/index.tsx b/src/components/MovieDetails/MovieCast/index.tsx index 0cc9c2e03..2006e9dfb 100644 --- a/src/components/MovieDetails/MovieCast/index.tsx +++ b/src/components/MovieDetails/MovieCast/index.tsx @@ -1,20 +1,19 @@ +import Header from '@app/components/Common/Header'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import PersonCard from '@app/components/PersonCard'; +import Error from '@app/pages/_error'; +import type { MovieDetails } from '@server/models/Movie'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import { MovieDetails } from '../../../../server/models/Movie'; -import Error from '../../../pages/_error'; -import Header from '../../Common/Header'; -import LoadingSpinner from '../../Common/LoadingSpinner'; -import PageTitle from '../../Common/PageTitle'; -import PersonCard from '../../PersonCard'; const messages = defineMessages({ fullcast: 'Full Cast', }); -const MovieCast: React.FC = () => { +const MovieCast = () => { const router = useRouter(); const intl = useIntl(); const { data, error } = useSWR( diff --git a/src/components/MovieDetails/MovieCrew/index.tsx b/src/components/MovieDetails/MovieCrew/index.tsx index 14268e425..1cc43b05a 100644 --- a/src/components/MovieDetails/MovieCrew/index.tsx +++ b/src/components/MovieDetails/MovieCrew/index.tsx @@ -1,20 +1,19 @@ +import Header from '@app/components/Common/Header'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import PersonCard from '@app/components/PersonCard'; +import Error from '@app/pages/_error'; +import type { MovieDetails } from '@server/models/Movie'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import { MovieDetails } from '../../../../server/models/Movie'; -import Error from '../../../pages/_error'; -import Header from '../../Common/Header'; -import LoadingSpinner from '../../Common/LoadingSpinner'; -import PageTitle from '../../Common/PageTitle'; -import PersonCard from '../../PersonCard'; const messages = defineMessages({ fullcrew: 'Full Crew', }); -const MovieCrew: React.FC = () => { +const MovieCrew = () => { const router = useRouter(); const intl = useIntl(); const { data, error } = useSWR( diff --git a/src/components/MovieDetails/MovieRecommendations.tsx b/src/components/MovieDetails/MovieRecommendations.tsx index fc9c2bf2c..a7635a259 100644 --- a/src/components/MovieDetails/MovieRecommendations.tsx +++ b/src/components/MovieDetails/MovieRecommendations.tsx @@ -1,21 +1,20 @@ +import Header from '@app/components/Common/Header'; +import ListView from '@app/components/Common/ListView'; +import PageTitle from '@app/components/Common/PageTitle'; +import useDiscover from '@app/hooks/useDiscover'; +import Error from '@app/pages/_error'; +import type { MovieDetails } from '@server/models/Movie'; +import type { MovieResult } from '@server/models/Search'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import type { MovieDetails } from '../../../server/models/Movie'; -import type { MovieResult } from '../../../server/models/Search'; -import useDiscover from '../../hooks/useDiscover'; -import Error from '../../pages/_error'; -import Header from '../Common/Header'; -import ListView from '../Common/ListView'; -import PageTitle from '../Common/PageTitle'; const messages = defineMessages({ recommendations: 'Recommendations', }); -const MovieRecommendations: React.FC = () => { +const MovieRecommendations = () => { const intl = useIntl(); const router = useRouter(); const { data: movieData } = useSWR( diff --git a/src/components/MovieDetails/MovieSimilar.tsx b/src/components/MovieDetails/MovieSimilar.tsx index 8103f966e..5ce5ef1a1 100644 --- a/src/components/MovieDetails/MovieSimilar.tsx +++ b/src/components/MovieDetails/MovieSimilar.tsx @@ -1,21 +1,20 @@ +import Header from '@app/components/Common/Header'; +import ListView from '@app/components/Common/ListView'; +import PageTitle from '@app/components/Common/PageTitle'; +import useDiscover from '@app/hooks/useDiscover'; +import Error from '@app/pages/_error'; +import type { MovieDetails } from '@server/models/Movie'; +import type { MovieResult } from '@server/models/Search'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import type { MovieDetails } from '../../../server/models/Movie'; -import type { MovieResult } from '../../../server/models/Search'; -import useDiscover from '../../hooks/useDiscover'; -import Error from '../../pages/_error'; -import Header from '../Common/Header'; -import ListView from '../Common/ListView'; -import PageTitle from '../Common/PageTitle'; const messages = defineMessages({ similar: 'Similar Titles', }); -const MovieSimilar: React.FC = () => { +const MovieSimilar = () => { const router = useRouter(); const intl = useIntl(); const { data: movieData } = useSWR( diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 3e33c4b8a..d963585bc 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -1,3 +1,29 @@ +import RTAudFresh from '@app/assets/rt_aud_fresh.svg'; +import RTAudRotten from '@app/assets/rt_aud_rotten.svg'; +import RTFresh from '@app/assets/rt_fresh.svg'; +import RTRotten from '@app/assets/rt_rotten.svg'; +import TmdbLogo from '@app/assets/tmdb_logo.svg'; +import Button from '@app/components/Common/Button'; +import CachedImage from '@app/components/Common/CachedImage'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import type { PlayButtonLink } from '@app/components/Common/PlayButton'; +import PlayButton from '@app/components/Common/PlayButton'; +import Tooltip from '@app/components/Common/Tooltip'; +import ExternalLinkBlock from '@app/components/ExternalLinkBlock'; +import IssueModal from '@app/components/IssueModal'; +import ManageSlideOver from '@app/components/ManageSlideOver'; +import MediaSlider from '@app/components/MediaSlider'; +import PersonCard from '@app/components/PersonCard'; +import RequestButton from '@app/components/RequestButton'; +import Slider from '@app/components/Slider'; +import StatusBadge from '@app/components/StatusBadge'; +import useLocale from '@app/hooks/useLocale'; +import useSettings from '@app/hooks/useSettings'; +import { Permission, useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; +import Error from '@app/pages/_error'; +import { sortCrewPriority } from '@app/utils/creditHelpers'; import { ArrowCircleRightIcon, CloudIcon, @@ -11,44 +37,20 @@ import { ChevronDoubleDownIcon, ChevronDoubleUpIcon, } from '@heroicons/react/solid'; +import type { RTRating } from '@server/api/rottentomatoes'; +import { IssueStatus } from '@server/constants/issue'; +import { MediaStatus } from '@server/constants/media'; +import { MediaServerType } from '@server/constants/server'; +import type { MovieDetails as MovieDetailsType } from '@server/models/Movie'; import { hasFlag } from 'country-flag-icons'; import 'country-flag-icons/3x2/flags.css'; import { uniqBy } from 'lodash'; +import getConfig from 'next/config'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React, { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import type { RTRating } from '../../../server/api/rottentomatoes'; -import { IssueStatus } from '../../../server/constants/issue'; -import { MediaStatus } from '../../../server/constants/media'; -import { MediaServerType } from '../../../server/constants/server'; -import type { MovieDetails as MovieDetailsType } from '../../../server/models/Movie'; -import RTAudFresh from '../../assets/rt_aud_fresh.svg'; -import RTAudRotten from '../../assets/rt_aud_rotten.svg'; -import RTFresh from '../../assets/rt_fresh.svg'; -import RTRotten from '../../assets/rt_rotten.svg'; -import TmdbLogo from '../../assets/tmdb_logo.svg'; -import useLocale from '../../hooks/useLocale'; -import useSettings from '../../hooks/useSettings'; -import { Permission, useUser } from '../../hooks/useUser'; -import globalMessages from '../../i18n/globalMessages'; -import Error from '../../pages/_error'; -import { sortCrewPriority } from '../../utils/creditHelpers'; -import Button from '../Common/Button'; -import CachedImage from '../Common/CachedImage'; -import LoadingSpinner from '../Common/LoadingSpinner'; -import PageTitle from '../Common/PageTitle'; -import PlayButton, { PlayButtonLink } from '../Common/PlayButton'; -import ExternalLinkBlock from '../ExternalLinkBlock'; -import IssueModal from '../IssueModal'; -import ManageSlideOver from '../ManageSlideOver'; -import MediaSlider from '../MediaSlider'; -import PersonCard from '../PersonCard'; -import RequestButton from '../RequestButton'; -import Slider from '../Slider'; -import StatusBadge from '../StatusBadge'; -import getConfig from 'next/config'; const messages = defineMessages({ originaltitle: 'Original Title', @@ -78,13 +80,21 @@ const messages = defineMessages({ streamingproviders: 'Currently Streaming On', productioncountries: 'Production {countryCount, plural, one {Country} other {Countries}}', + theatricalrelease: 'Theatrical Release', + digitalrelease: 'Digital Release', + physicalrelease: 'Physical Release', + reportissue: 'Report an Issue', + managemovie: 'Manage Movie', + rtcriticsscore: 'Rotten Tomatoes Tomatometer', + rtaudiencescore: 'Rotten Tomatoes Audience Score', + tmdbuserscore: 'TMDB User Score', }); interface MovieDetailsProps { movie?: MovieDetailsType; } -const MovieDetails: React.FC = ({ movie }) => { +const MovieDetails = ({ movie }: MovieDetailsProps) => { const settings = useSettings(); const { user, hasPermission } = useUser(); const router = useRouter(); @@ -119,6 +129,32 @@ const MovieDetails: React.FC = ({ movie }) => { setShowManager(router.query.manage == '1' ? true : false); }, [router.query.manage]); + const [plexUrl, setPlexUrl] = useState(data?.mediaInfo?.mediaUrl); + const [plexUrl4k, setPlexUrl4k] = useState(data?.mediaInfo?.mediaUrl4k); + + useEffect(() => { + if (data) { + if ( + settings.currentSettings.mediaServerType === MediaServerType.PLEX && + (/iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.userAgent === 'MacIntel' && navigator.maxTouchPoints > 1)) + ) { + setPlexUrl(data.mediaInfo?.iOSPlexUrl); + setPlexUrl4k(data.mediaInfo?.iOSPlexUrl4k); + } else { + setPlexUrl(data.mediaInfo?.mediaUrl); + setPlexUrl4k(data.mediaInfo?.mediaUrl4k); + } + } + }, [ + data, + data?.mediaInfo?.iOSPlexUrl, + data?.mediaInfo?.iOSPlexUrl4k, + data?.mediaInfo?.mediaUrl, + data?.mediaInfo?.mediaUrl4k, + settings.currentSettings.mediaServerType, + ]); + if (!data && !error) { return ; } @@ -130,27 +166,32 @@ const MovieDetails: React.FC = ({ movie }) => { const showAllStudios = data.productionCompanies.length <= minStudios + 1; const mediaLinks: PlayButtonLink[] = []; - if (data.mediaInfo?.mediaUrl) { + if ( + plexUrl && + hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], { + type: 'or', + }) + ) { mediaLinks.push({ text: getAvalaibleMediaServerName(), - url: data.mediaInfo?.mediaUrl, + url: plexUrl, svg: , }); } if ( - data.mediaInfo?.mediaUrl4k && - hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], { + settings.currentSettings.movie4kEnabled && + plexUrl4k && + hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], { type: 'or', }) ) { mediaLinks.push({ text: getAvalaible4kMediaServerName(), - url: data.mediaInfo?.mediaUrl4k, + url: plexUrl4k, svg: , }); } - const trailerUrl = data.relatedVideos ?.filter((r) => r.type === 'Trailer') .sort((a, b) => a.size - b.size) @@ -315,7 +356,8 @@ const MovieDetails: React.FC = ({ movie }) => { inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0} tmdbId={data.mediaInfo?.tmdbId} mediaType="movie" - plexUrl={data.mediaInfo?.mediaUrl} + plexUrl={plexUrl} + serviceUrl={data.mediaInfo?.serviceUrl} /> {settings.currentSettings.movie4kEnabled && hasPermission( @@ -336,11 +378,12 @@ const MovieDetails: React.FC = ({ movie }) => { } tmdbId={data.mediaInfo?.tmdbId} mediaType="movie" - plexUrl={data.mediaInfo?.mediaUrl4k} + plexUrl={plexUrl} + serviceUrl={data.mediaInfo?.serviceUrl4k} /> )}
        -

        +

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

        @@ -489,36 +536,55 @@ const MovieDetails: React.FC = ({ movie }) => { (ratingData?.audienceRating && !!ratingData?.audienceScore)) && ( )} @@ -548,22 +614,36 @@ const MovieDetails: React.FC = ({ movie }) => { > {r.type === 3 ? ( // Theatrical - + + + ) : r.type === 4 ? ( // Digital - + + + ) : ( // Physical - - - + + + + )} {intl.formatDate(r.release_date, { diff --git a/src/components/NotificationTypeSelector/NotificationType/index.tsx b/src/components/NotificationTypeSelector/NotificationType/index.tsx index 9662ebd36..f0e6cb059 100644 --- a/src/components/NotificationTypeSelector/NotificationType/index.tsx +++ b/src/components/NotificationTypeSelector/NotificationType/index.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { hasNotificationType, NotificationItem } from '..'; +import type { NotificationItem } from '@app/components/NotificationTypeSelector'; +import { hasNotificationType } from '@app/components/NotificationTypeSelector'; interface NotificationTypeProps { option: NotificationItem; @@ -8,12 +8,12 @@ interface NotificationTypeProps { onUpdate: (newTypes: number) => void; } -const NotificationType: React.FC = ({ +const NotificationType = ({ option, currentTypes, onUpdate, parent, -}) => { +}: NotificationTypeProps) => { return ( <>
        = ({ +const NotificationTypeSelector = ({ user, enabledTypes = ALL_NOTIFICATIONS, currentTypes, onUpdate, error, -}) => { +}: NotificationTypeSelectorProps) => { const intl = useIntl(); const settings = useSettings(); const { hasPermission } = useUser({ id: user?.id }); @@ -190,6 +195,25 @@ const NotificationTypeSelector: React.FC = ({ )))); const types: NotificationItem[] = [ + { + id: 'media-auto-requested', + name: intl.formatMessage(messages.mediaautorequested), + description: intl.formatMessage(messages.mediaautorequestedDescription), + value: Notification.MEDIA_AUTO_REQUESTED, + hidden: + !user || + (!user.settings?.watchlistSyncMovies && + !user.settings?.watchlistSyncTv) || + !hasPermission( + [ + Permission.AUTO_REQUEST, + Permission.AUTO_REQUEST_MOVIE, + Permission.AUTO_REQUEST_TV, + ], + { type: 'or' } + ), + hasNotifyUser: true, + }, { id: 'media-requested', name: intl.formatMessage(messages.mediarequested), diff --git a/src/components/PWAHeader/index.tsx b/src/components/PWAHeader/index.tsx index 1c53abfb1..0dde7e421 100644 --- a/src/components/PWAHeader/index.tsx +++ b/src/components/PWAHeader/index.tsx @@ -1,12 +1,8 @@ -import React from 'react'; - interface PWAHeaderProps { applicationTitle?: string; } -const PWAHeader: React.FC = ({ - applicationTitle = 'Overseerr', -}) => { +const PWAHeader = ({ applicationTitle = 'Overseerr' }: PWAHeaderProps) => { return ( <> void; } -export const PermissionEdit: React.FC = ({ +export const PermissionEdit = ({ actingUser, currentUser, currentPermission, onUpdate, -}) => { +}: PermissionEditProps) => { const intl = useIntl(); const permissionList: PermissionItem[] = [ @@ -86,12 +99,6 @@ export const PermissionEdit: React.FC = ({ description: intl.formatMessage(messages.adminDescription), permission: Permission.ADMIN, }, - { - id: 'settings', - name: intl.formatMessage(messages.settings), - description: intl.formatMessage(messages.settingsDescription), - permission: Permission.MANAGE_SETTINGS, - }, { id: 'users', name: intl.formatMessage(messages.users), @@ -116,6 +123,18 @@ export const PermissionEdit: React.FC = ({ description: intl.formatMessage(messages.viewrequestsDescription), permission: Permission.REQUEST_VIEW, }, + { + id: 'viewrecent', + name: intl.formatMessage(messages.viewrecent), + description: intl.formatMessage(messages.viewrecentDescription), + permission: Permission.RECENT_VIEW, + }, + { + id: 'viewwatchlists', + name: intl.formatMessage(messages.viewwatchlists), + description: intl.formatMessage(messages.viewwatchlistsDescription), + permission: Permission.WATCHLIST_VIEW, + }, ], }, { @@ -175,6 +194,43 @@ export const PermissionEdit: React.FC = ({ }, ], }, + { + id: 'autorequest', + name: intl.formatMessage(messages.autorequest), + description: intl.formatMessage(messages.autorequestDescription), + permission: Permission.AUTO_REQUEST, + requires: [{ permissions: [Permission.REQUEST] }], + children: [ + { + id: 'autorequestmovies', + name: intl.formatMessage(messages.autorequestMovies), + description: intl.formatMessage( + messages.autorequestMoviesDescription + ), + permission: Permission.AUTO_REQUEST_MOVIE, + requires: [ + { + permissions: [Permission.REQUEST, Permission.REQUEST_MOVIE], + type: 'or', + }, + ], + }, + { + id: 'autorequesttv', + name: intl.formatMessage(messages.autorequestSeries), + description: intl.formatMessage( + messages.autorequestSeriesDescription + ), + permission: Permission.AUTO_REQUEST_TV, + requires: [ + { + permissions: [Permission.REQUEST, Permission.REQUEST_TV], + type: 'or', + }, + ], + }, + ], + }, { id: 'request4k', name: intl.formatMessage(messages.request4k), diff --git a/src/components/PermissionOption/index.tsx b/src/components/PermissionOption/index.tsx index 739234759..43d5128da 100644 --- a/src/components/PermissionOption/index.tsx +++ b/src/components/PermissionOption/index.tsx @@ -1,7 +1,7 @@ -import React from 'react'; -import { hasPermission } from '../../../server/lib/permissions'; -import useSettings from '../../hooks/useSettings'; -import { Permission, User } from '../../hooks/useUser'; +import useSettings from '@app/hooks/useSettings'; +import type { User } from '@app/hooks/useUser'; +import { Permission } from '@app/hooks/useUser'; +import { hasPermission } from '@server/lib/permissions'; export interface PermissionItem { id: string; @@ -26,14 +26,14 @@ interface PermissionOptionProps { onUpdate: (newPermissions: number) => void; } -const PermissionOption: React.FC = ({ +const PermissionOption = ({ option, actingUser, currentUser, currentPermission, onUpdate, parent, -}) => { +}: PermissionOptionProps) => { const settings = useSettings(); const autoApprovePermissions = [ @@ -66,14 +66,9 @@ const PermissionOption: React.FC = ({ } if ( - // Non-Admin users cannot modify the Admin permission - (actingUser && - !hasPermission(Permission.ADMIN, actingUser.permissions) && - option.permission === Permission.ADMIN) || - // Users without the Manage Settings permission cannot modify/grant that permission - (actingUser && - !hasPermission(Permission.MANAGE_SETTINGS, actingUser.permissions) && - option.permission === Permission.MANAGE_SETTINGS) + // Only the owner can modify the Admin permission + actingUser?.id !== 1 && + option.permission === Permission.ADMIN ) { disabled = true; } diff --git a/src/components/PersonCard/index.tsx b/src/components/PersonCard/index.tsx index 47fe56efc..c2b7b6422 100644 --- a/src/components/PersonCard/index.tsx +++ b/src/components/PersonCard/index.tsx @@ -1,7 +1,7 @@ +import CachedImage from '@app/components/Common/CachedImage'; import { UserCircleIcon } from '@heroicons/react/solid'; import Link from 'next/link'; -import React, { useState } from 'react'; -import CachedImage from '../Common/CachedImage'; +import { useState } from 'react'; interface PersonCardProps { personId: number; @@ -11,13 +11,13 @@ interface PersonCardProps { canExpand?: boolean; } -const PersonCard: React.FC = ({ +const PersonCard = ({ personId, name, subName, profilePath, canExpand = false, -}) => { +}: PersonCardProps) => { const [isHovered, setHovered] = useState(false); return ( diff --git a/src/components/PersonDetails/index.tsx b/src/components/PersonDetails/index.tsx index 173fc5dab..9c8173adc 100644 --- a/src/components/PersonDetails/index.tsx +++ b/src/components/PersonDetails/index.tsx @@ -1,19 +1,19 @@ +import Ellipsis from '@app/assets/ellipsis.svg'; +import CachedImage from '@app/components/Common/CachedImage'; +import ImageFader from '@app/components/Common/ImageFader'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import TitleCard from '@app/components/TitleCard'; +import globalMessages from '@app/i18n/globalMessages'; +import Error from '@app/pages/_error'; +import type { PersonCombinedCreditsResponse } from '@server/interfaces/api/personInterfaces'; +import type { PersonDetails as PersonDetailsType } from '@server/models/Person'; import { groupBy } from 'lodash'; import { useRouter } from 'next/router'; -import React, { useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import TruncateMarkup from 'react-truncate-markup'; import useSWR from 'swr'; -import type { PersonCombinedCreditsResponse } from '../../../server/interfaces/api/personInterfaces'; -import type { PersonDetails as PersonDetailsType } from '../../../server/models/Person'; -import Ellipsis from '../../assets/ellipsis.svg'; -import globalMessages from '../../i18n/globalMessages'; -import Error from '../../pages/_error'; -import CachedImage from '../Common/CachedImage'; -import ImageFader from '../Common/ImageFader'; -import LoadingSpinner from '../Common/LoadingSpinner'; -import PageTitle from '../Common/PageTitle'; -import TitleCard from '../TitleCard'; const messages = defineMessages({ birthdate: 'Born {birthdate}', @@ -24,7 +24,7 @@ const messages = defineMessages({ ascharacter: 'as {character}', }); -const PersonDetails: React.FC = () => { +const PersonDetails = () => { const intl = useIntl(); const router = useRouter(); const { data, error } = useSWR( diff --git a/src/components/PlexLoginButton/index.tsx b/src/components/PlexLoginButton/index.tsx index 550938716..c89f10213 100644 --- a/src/components/PlexLoginButton/index.tsx +++ b/src/components/PlexLoginButton/index.tsx @@ -1,8 +1,8 @@ +import globalMessages from '@app/i18n/globalMessages'; +import PlexOAuth from '@app/utils/plex'; import { LoginIcon } from '@heroicons/react/outline'; -import React, { useState } from 'react'; +import { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import globalMessages from '../../i18n/globalMessages'; -import PlexOAuth from '../../utils/plex'; const messages = defineMessages({ signinwithplex: 'Sign In', @@ -17,11 +17,11 @@ interface PlexLoginButtonProps { onError?: (message: string) => void; } -const PlexLoginButton: React.FC = ({ +const PlexLoginButton = ({ onAuthToken, onError, isProcessing, -}) => { +}: PlexLoginButtonProps) => { const intl = useIntl(); const [loading, setLoading] = useState(false); diff --git a/src/components/PullToRefresh/index.tsx b/src/components/PullToRefresh/index.tsx new file mode 100644 index 000000000..ce92ea605 --- /dev/null +++ b/src/components/PullToRefresh/index.tsx @@ -0,0 +1,40 @@ +import { RefreshIcon } from '@heroicons/react/outline'; +import Router from 'next/router'; +import PR from 'pulltorefreshjs'; +import { useEffect } from 'react'; +import ReactDOMServer from 'react-dom/server'; + +const PullToRefresh: React.FC = () => { + useEffect(() => { + PR.init({ + mainElement: '#pull-to-refresh', + onRefresh() { + Router.reload(); + }, + iconArrow: ReactDOMServer.renderToString( +
        + +
        + ), + iconRefreshing: ReactDOMServer.renderToString( +
        + +
        + ), + instructionsPullToRefresh: ReactDOMServer.renderToString(
        ), + instructionsReleaseToRefresh: ReactDOMServer.renderToString(
        ), + instructionsRefreshing: ReactDOMServer.renderToString(
        ), + distReload: 60, + }); + return () => { + PR.destroyAll(); + }; + }, []); + + return
        ; +}; + +export default PullToRefresh; diff --git a/src/components/QuotaSelector/index.tsx b/src/components/QuotaSelector/index.tsx index 9ad39e221..7240dbc28 100644 --- a/src/components/QuotaSelector/index.tsx +++ b/src/components/QuotaSelector/index.tsx @@ -24,7 +24,7 @@ interface QuotaSelectorProps { onChange: (fieldName: string, value: number) => void; } -const QuotaSelector: React.FC = ({ +const QuotaSelector = ({ mediaType, dayFieldName, limitFieldName, @@ -34,7 +34,7 @@ const QuotaSelector: React.FC = ({ limitOverride, isDisabled = false, onChange, -}) => { +}: QuotaSelectorProps) => { const initialDays = defaultDays ?? 7; const initialLimit = defaultLimit ?? 0; const [quotaDays, setQuotaDays] = useState(initialDays); diff --git a/src/components/RegionSelector/index.tsx b/src/components/RegionSelector/index.tsx index 0c4bb2c6c..5a714c742 100644 --- a/src/components/RegionSelector/index.tsx +++ b/src/components/RegionSelector/index.tsx @@ -1,13 +1,13 @@ +import useSettings from '@app/hooks/useSettings'; import { Listbox, Transition } from '@headlessui/react'; import { CheckIcon, ChevronDownIcon } from '@heroicons/react/solid'; +import type { Region } from '@server/lib/settings'; import { hasFlag } from 'country-flag-icons'; import 'country-flag-icons/3x2/flags.css'; import { sortBy } from 'lodash'; -import React, { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import type { Region } from '../../../server/lib/settings'; -import useSettings from '../../hooks/useSettings'; const messages = defineMessages({ regionDefault: 'All Regions', @@ -21,12 +21,12 @@ interface RegionSelectorProps { onChange?: (fieldName: string, region: string) => void; } -const RegionSelector: React.FC = ({ +const RegionSelector = ({ name, value, isUserSetting = false, onChange, -}) => { +}: RegionSelectorProps) => { const { currentSettings } = useSettings(); const intl = useIntl(); const { data: regions } = useSWR('/api/v1/regions'); diff --git a/src/components/RequestBlock/index.tsx b/src/components/RequestBlock/index.tsx index d1d4ae8a6..e6a0c02bb 100644 --- a/src/components/RequestBlock/index.tsx +++ b/src/components/RequestBlock/index.tsx @@ -1,3 +1,10 @@ +import Badge from '@app/components/Common/Badge'; +import Button from '@app/components/Common/Button'; +import Tooltip from '@app/components/Common/Tooltip'; +import RequestModal from '@app/components/RequestModal'; +import useRequestOverride from '@app/hooks/useRequestOverride'; +import { useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; import { CalendarIcon, CheckIcon, @@ -7,18 +14,12 @@ import { UserIcon, XIcon, } from '@heroicons/react/solid'; +import { MediaRequestStatus } from '@server/constants/media'; +import type { MediaRequest } from '@server/entity/MediaRequest'; import axios from 'axios'; import Link from 'next/link'; -import React, { useState } from 'react'; +import { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { MediaRequestStatus } from '../../../server/constants/media'; -import type { MediaRequest } from '../../../server/entity/MediaRequest'; -import useRequestOverride from '../../hooks/useRequestOverride'; -import { useUser } from '../../hooks/useUser'; -import globalMessages from '../../i18n/globalMessages'; -import Badge from '../Common/Badge'; -import Button from '../Common/Button'; -import RequestModal from '../RequestModal'; const messages = defineMessages({ seasons: '{seasonCount, plural, one {Season} other {Seasons}}', @@ -27,6 +28,13 @@ const messages = defineMessages({ profilechanged: 'Quality Profile', rootfolder: 'Root Folder', languageprofile: 'Language Profile', + requestdate: 'Request Date', + requestedby: 'Requested By', + lastmodifiedby: 'Last Modified By', + approve: 'Approve Request', + decline: 'Decline Request', + edit: 'Edit Request', + delete: 'Delete Request', }); interface RequestBlockProps { @@ -34,7 +42,7 @@ interface RequestBlockProps { onUpdate?: () => void; } -const RequestBlock: React.FC = ({ request, onUpdate }) => { +const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => { const { user } = useUser(); const intl = useIntl(); const [isUpdating, setIsUpdating] = useState(false); @@ -83,7 +91,9 @@ const RequestBlock: React.FC = ({ request, onUpdate }) => {
        - + + + = ({ request, onUpdate }) => {
        {request.modifiedBy && (
        - + + + = ({ request, onUpdate }) => {
        {request.status === MediaRequestStatus.PENDING && ( <> - + + + + + + + + + + + )} + {request.status !== MediaRequestStatus.PENDING && ( + - - - )} - {request.status !== MediaRequestStatus.PENDING && ( - + )}
        @@ -179,10 +199,17 @@ const RequestBlock: React.FC = ({ request, onUpdate }) => { {intl.formatMessage(globalMessages.pending)} )} + {request.status === MediaRequestStatus.FAILED && ( + + {intl.formatMessage(globalMessages.failed)} + + )}
        - + + + {intl.formatDate(request.createdAt, { year: 'numeric', diff --git a/src/components/RequestButton/index.tsx b/src/components/RequestButton/index.tsx index 5ba5bf5d5..f71589448 100644 --- a/src/components/RequestButton/index.tsx +++ b/src/components/RequestButton/index.tsx @@ -1,23 +1,20 @@ +import ButtonWithDropdown from '@app/components/Common/ButtonWithDropdown'; +import RequestModal from '@app/components/RequestModal'; +import useSettings from '@app/hooks/useSettings'; +import { Permission, useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; import { DownloadIcon } from '@heroicons/react/outline'; import { CheckIcon, InformationCircleIcon, XIcon, } from '@heroicons/react/solid'; +import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; +import type Media from '@server/entity/Media'; +import type { MediaRequest } from '@server/entity/MediaRequest'; import axios from 'axios'; -import React, { useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { - MediaRequestStatus, - MediaStatus, -} from '../../../server/constants/media'; -import Media from '../../../server/entity/Media'; -import { MediaRequest } from '../../../server/entity/MediaRequest'; -import useSettings from '../../hooks/useSettings'; -import { Permission, useUser } from '../../hooks/useUser'; -import globalMessages from '../../i18n/globalMessages'; -import ButtonWithDropdown from '../Common/ButtonWithDropdown'; -import RequestModal from '../RequestModal'; const messages = defineMessages({ viewrequest: 'View Request', @@ -54,14 +51,14 @@ interface RequestButtonProps { is4kShowComplete?: boolean; } -const RequestButton: React.FC = ({ +const RequestButton = ({ tmdbId, onUpdate, media, mediaType, isShowComplete = false, is4kShowComplete = false, -}) => { +}: RequestButtonProps) => { const intl = useIntl(); const settings = useSettings(); const { user, hasPermission } = useUser(); @@ -77,13 +74,13 @@ const RequestButton: React.FC = ({ (request) => request.status === MediaRequestStatus.PENDING && request.is4k ); + // Current user's pending request, or the first pending request const activeRequest = useMemo(() => { return activeRequests && activeRequests.length > 0 ? activeRequests.find((request) => request.requestedBy.id === user?.id) ?? activeRequests[0] : undefined; }, [activeRequests, user]); - const active4kRequest = useMemo(() => { return active4kRequests && active4kRequests.length > 0 ? active4kRequests.find( @@ -121,6 +118,151 @@ const RequestButton: React.FC = ({ }; const buttons: ButtonOption[] = []; + + // If there are pending requests, show request management options first + if (activeRequest || active4kRequest) { + if ( + activeRequest && + (activeRequest.requestedBy.id === user?.id || + (activeRequests?.length === 1 && + hasPermission(Permission.MANAGE_REQUESTS))) + ) { + buttons.push({ + id: 'active-request', + text: intl.formatMessage(messages.viewrequest), + action: () => { + setEditRequest(true); + setShowRequestModal(true); + }, + svg: , + }); + } + + if ( + activeRequest && + hasPermission(Permission.MANAGE_REQUESTS) && + mediaType === 'movie' + ) { + buttons.push( + { + id: 'approve-request', + text: intl.formatMessage(messages.approverequest), + action: () => { + modifyRequest(activeRequest, 'approve'); + }, + svg: , + }, + { + id: 'decline-request', + text: intl.formatMessage(messages.declinerequest), + action: () => { + modifyRequest(activeRequest, 'decline'); + }, + svg: , + } + ); + } else if ( + activeRequests && + activeRequests.length > 0 && + hasPermission(Permission.MANAGE_REQUESTS) && + mediaType === 'tv' + ) { + buttons.push( + { + id: 'approve-request-batch', + text: intl.formatMessage(messages.approverequests, { + requestCount: activeRequests.length, + }), + action: () => { + modifyRequests(activeRequests, 'approve'); + }, + svg: , + }, + { + id: 'decline-request-batch', + text: intl.formatMessage(messages.declinerequests, { + requestCount: activeRequests.length, + }), + action: () => { + modifyRequests(activeRequests, 'decline'); + }, + svg: , + } + ); + } + + if ( + active4kRequest && + (active4kRequest.requestedBy.id === user?.id || + (active4kRequests?.length === 1 && + hasPermission(Permission.MANAGE_REQUESTS))) + ) { + buttons.push({ + id: 'active-4k-request', + text: intl.formatMessage(messages.viewrequest4k), + action: () => { + setEditRequest(true); + setShowRequest4kModal(true); + }, + svg: , + }); + } + + if ( + active4kRequest && + hasPermission(Permission.MANAGE_REQUESTS) && + mediaType === 'movie' + ) { + buttons.push( + { + id: 'approve-4k-request', + text: intl.formatMessage(messages.approverequest4k), + action: () => { + modifyRequest(active4kRequest, 'approve'); + }, + svg: , + }, + { + id: 'decline-4k-request', + text: intl.formatMessage(messages.declinerequest4k), + action: () => { + modifyRequest(active4kRequest, 'decline'); + }, + svg: , + } + ); + } else if ( + active4kRequests && + active4kRequests.length > 0 && + hasPermission(Permission.MANAGE_REQUESTS) && + mediaType === 'tv' + ) { + buttons.push( + { + id: 'approve-4k-request-batch', + text: intl.formatMessage(messages.approve4krequests, { + requestCount: active4kRequests.length, + }), + action: () => { + modifyRequests(active4kRequests, 'approve'); + }, + svg: , + }, + { + id: 'decline-4k-request-batch', + text: intl.formatMessage(messages.decline4krequests, { + requestCount: active4kRequests.length, + }), + action: () => { + modifyRequests(active4kRequests, 'decline'); + }, + svg: , + } + ); + } + } + + // Standard request button if ( (!media || media.status === MediaStatus.UNKNOWN) && hasPermission( @@ -142,8 +284,28 @@ const RequestButton: React.FC = ({ }, svg: , }); + } else if ( + mediaType === 'tv' && + (!activeRequest || activeRequest.requestedBy.id !== user?.id) && + hasPermission([Permission.REQUEST, Permission.REQUEST_TV], { + type: 'or', + }) && + media && + media.status !== MediaStatus.AVAILABLE && + !isShowComplete + ) { + buttons.push({ + id: 'request-more', + text: intl.formatMessage(messages.requestmore), + action: () => { + setEditRequest(false); + setShowRequestModal(true); + }, + svg: , + }); } + // 4K request button if ( (!media || media.status4k === MediaStatus.UNKNOWN) && hasPermission( @@ -167,175 +329,7 @@ const RequestButton: React.FC = ({ }, svg: , }); - } - - if ( - activeRequest && - (activeRequest.requestedBy.id === user?.id || - (activeRequests?.length === 1 && - hasPermission(Permission.MANAGE_REQUESTS))) - ) { - buttons.push({ - id: 'active-request', - text: intl.formatMessage(messages.viewrequest), - action: () => { - setEditRequest(true); - setShowRequestModal(true); - }, - svg: , - }); - } - - if ( - active4kRequest && - (active4kRequest.requestedBy.id === user?.id || - (active4kRequests?.length === 1 && - hasPermission(Permission.MANAGE_REQUESTS))) - ) { - buttons.push({ - id: 'active-4k-request', - text: intl.formatMessage(messages.viewrequest4k), - action: () => { - setEditRequest(true); - setShowRequest4kModal(true); - }, - svg: , - }); - } - - if ( - activeRequest && - hasPermission(Permission.MANAGE_REQUESTS) && - mediaType === 'movie' - ) { - buttons.push( - { - id: 'approve-request', - text: intl.formatMessage(messages.approverequest), - action: () => { - modifyRequest(activeRequest, 'approve'); - }, - svg: , - }, - { - id: 'decline-request', - text: intl.formatMessage(messages.declinerequest), - action: () => { - modifyRequest(activeRequest, 'decline'); - }, - svg: , - } - ); - } - - if ( - activeRequests && - activeRequests.length > 0 && - hasPermission(Permission.MANAGE_REQUESTS) && - mediaType === 'tv' - ) { - buttons.push( - { - id: 'approve-request-batch', - text: intl.formatMessage(messages.approverequests, { - requestCount: activeRequests.length, - }), - action: () => { - modifyRequests(activeRequests, 'approve'); - }, - svg: , - }, - { - id: 'decline-request-batch', - text: intl.formatMessage(messages.declinerequests, { - requestCount: activeRequests.length, - }), - action: () => { - modifyRequests(activeRequests, 'decline'); - }, - svg: , - } - ); - } - - if ( - active4kRequest && - hasPermission(Permission.MANAGE_REQUESTS) && - mediaType === 'movie' - ) { - buttons.push( - { - id: 'approve-4k-request', - text: intl.formatMessage(messages.approverequest4k), - action: () => { - modifyRequest(active4kRequest, 'approve'); - }, - svg: , - }, - { - id: 'decline-4k-request', - text: intl.formatMessage(messages.declinerequest4k), - action: () => { - modifyRequest(active4kRequest, 'decline'); - }, - svg: , - } - ); - } - - if ( - active4kRequests && - active4kRequests.length > 0 && - hasPermission(Permission.MANAGE_REQUESTS) && - mediaType === 'tv' - ) { - buttons.push( - { - id: 'approve-4k-request-batch', - text: intl.formatMessage(messages.approve4krequests, { - requestCount: active4kRequests.length, - }), - action: () => { - modifyRequests(active4kRequests, 'approve'); - }, - svg: , - }, - { - id: 'decline-4k-request-batch', - text: intl.formatMessage(messages.decline4krequests, { - requestCount: active4kRequests.length, - }), - action: () => { - modifyRequests(active4kRequests, 'decline'); - }, - svg: , - } - ); - } - - if ( - mediaType === 'tv' && - (!activeRequest || activeRequest.requestedBy.id !== user?.id) && - hasPermission([Permission.REQUEST, Permission.REQUEST_TV], { - type: 'or', - }) && - media && - media.status !== MediaStatus.AVAILABLE && - media.status !== MediaStatus.UNKNOWN && - !isShowComplete - ) { - buttons.push({ - id: 'request-more', - text: intl.formatMessage(messages.requestmore), - action: () => { - setEditRequest(false); - setShowRequestModal(true); - }, - svg: , - }); - } - - if ( + } else if ( mediaType === 'tv' && (!active4kRequest || active4kRequest.requestedBy.id !== user?.id) && hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], { @@ -343,7 +337,6 @@ const RequestButton: React.FC = ({ }) && media && media.status4k !== MediaStatus.AVAILABLE && - media.status4k !== MediaStatus.UNKNOWN && !is4kShowComplete && settings.currentSettings.series4kEnabled ) { diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index 4ac1bfe9b..9ccbcde02 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, @@ -251,20 +357,13 @@ const RequestCard: React.FC = ({ request, onTitleData }) => { : request.seasons.length, })} - {title.seasons.filter((season) => season.seasonNumber !== 0) - .length === request.seasons.length ? ( - - {intl.formatMessage(globalMessages.all)} - - ) : ( -
        - {request.seasons.map((season) => ( - - {season.seasonNumber} - - ))} -
        - )} +
        + {request.seasons.map((season) => ( + + {season.seasonNumber} + + ))} +
        )}
        @@ -275,8 +374,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 +483,54 @@ const RequestCard: React.FC = ({ request, onTitleData }) => { requestData.requestedBy.id === user?.id && (requestData.type === 'tv' || hasPermission(Permission.REQUEST_ADVANCED)) && ( - +
        + {!hasPermission(Permission.MANAGE_REQUESTS) && ( + + )} + + + +
        )} {requestData.status === MediaRequestStatus.PENDING && !hasPermission(Permission.MANAGE_REQUESTS) && requestData.requestedBy.id === user?.id && ( - +
        + + + + +
        )}
        diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 6c98281ec..6c232dc89 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 ( ); @@ -249,20 +420,13 @@ const RequestItem: React.FC = ({ : request.seasons.length, })} - {title.seasons.filter((season) => season.seasonNumber !== 0) - .length === request.seasons.length ? ( - - {intl.formatMessage(globalMessages.all)} - - ) : ( -
        - {request.seasons.map((season) => ( - - {season.seasonNumber} - - ))} -
        - )} +
        + {request.seasons.map((season) => ( + + {season.seasonNumber} + + ))} +
        )}
        @@ -276,9 +440,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 +572,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) && (