diff --git a/.all-contributorsrc b/.all-contributorsrc index a230a468..3cf5e765 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -665,6 +665,78 @@ "contributions": [ "translation" ] + }, + { + "login": "sambartik", + "name": "Samuel Bartík", + "avatar_url": "https://avatars.githubusercontent.com/u/63553146?v=4", + "profile": "https://github.com/sambartik", + "contributions": [ + "code" + ] + }, + { + "login": "frank-cywong", + "name": "Chun Yeung Wong", + "avatar_url": "https://avatars.githubusercontent.com/u/90653148?v=4", + "profile": "https://github.com/frank-cywong", + "contributions": [ + "code" + ] + }, + { + "login": "TheMeanCanEHdian", + "name": "TheMeanCanEHdian", + "avatar_url": "https://avatars.githubusercontent.com/u/16025103?v=4", + "profile": "https://github.com/TheMeanCanEHdian", + "contributions": [ + "code" + ] + }, + { + "login": "Gylesie", + "name": "Gylesie", + "avatar_url": "https://avatars.githubusercontent.com/u/86306812?v=4", + "profile": "https://github.com/Gylesie", + "contributions": [ + "code" + ] + }, + { + "login": "Fhd-pro", + "name": "Fhd-pro", + "avatar_url": "https://avatars.githubusercontent.com/u/82862079?v=4", + "profile": "https://github.com/Fhd-pro", + "contributions": [ + "translation" + ] + }, + { + "login": "PovilasID", + "name": "PovilasID", + "avatar_url": "https://avatars.githubusercontent.com/u/396243?v=4", + "profile": "https://github.com/PovilasID", + "contributions": [ + "translation" + ] + }, + { + "login": "byakurau", + "name": "byakurau", + "avatar_url": "https://avatars.githubusercontent.com/u/1811683?v=4", + "profile": "https://github.com/byakurau", + "contributions": [ + "translation" + ] + }, + { + "login": "miknii", + "name": "miknii", + "avatar_url": "https://avatars.githubusercontent.com/u/109232569?v=4", + "profile": "https://github.com/miknii", + "contributions": [ + "translation" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", @@ -673,5 +745,5 @@ "projectOwner": "sct", "repoType": "github", "repoHost": "https://github.com", - "skipCi": true + "skipCi": false } diff --git a/.dockerignore b/.dockerignore index 7d669c86..21a5da86 100644 --- a/.dockerignore +++ b/.dockerignore @@ -26,3 +26,4 @@ public/os_logo_filled.png public/preview.jpg snap stylelint.config.js +cypress diff --git a/.eslintrc.js b/.eslintrc.js index b1c6f4b9..5af484c5 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -7,6 +7,7 @@ module.exports = { 'plugin:jsx-a11y/recommended', 'plugin:react/recommended', 'plugin:react-hooks/recommended', + 'plugin:react/jsx-runtime', 'prettier', ], parserOptions: { @@ -26,11 +27,21 @@ module.exports = { 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'warn', '@typescript-eslint/explicit-function-return-type': 'off', - 'prettier/prettier': ['error', { endOfLine: 'auto' }], 'formatjs/no-offset': 'error', 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': ['error'], + '@typescript-eslint/array-type': ['error', { default: 'array' }], 'jsx-a11y/no-onchange': 'off', + '@typescript-eslint/consistent-type-imports': [ + 'error', + { + prefer: 'type-imports', + }, + ], + 'no-relative-import-paths/no-relative-import-paths': [ + 'error', + { allowSameFolder: true }, + ], }, overrides: [ { @@ -40,7 +51,7 @@ module.exports = { }, }, ], - plugins: ['jsx-a11y', 'prettier', 'react-hooks', 'formatjs'], + plugins: ['jsx-a11y', 'react-hooks', 'formatjs', 'no-relative-import-paths'], settings: { react: { pragma: 'React', diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44215bde..d2026ee7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: Jellyseerr CI on: pull_request: branches: - - "*" + - '*' push: branches: - develop @@ -13,16 +13,18 @@ jobs: name: Lint & Test Build if: github.event_name == 'pull_request' runs-on: ubuntu-20.04 - container: node:16.14-alpine + container: node:16.17-alpine steps: - name: Checkout uses: actions/checkout@v3 - name: Install dependencies env: - HUSKY_SKIP_INSTALL: 1 + HUSKY: 0 run: yarn - name: Lint run: yarn lint + - name: Formatting + run: yarn format:check - name: Build run: yarn build @@ -34,23 +36,29 @@ jobs: - name: Checkout uses: actions/checkout@v3 - name: Set up QEMU - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v2 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v2 - name: Cache Docker layers - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ github.sha }} restore-keys: | ${{ runner.os }}-buildx- - name: Log in to Docker Hub - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} + - name: Log in to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v3 with: context: . file: ./Dockerfile @@ -77,7 +85,7 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Get Build Job Status - uses: technote-space/workflow-conclusion-action@v2 + uses: technote-space/workflow-conclusion-action@v3 - name: Combine Job Status id: status run: | diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml new file mode 100644 index 00000000..ecd260dd --- /dev/null +++ b/.github/workflows/cypress.yml @@ -0,0 +1,30 @@ +name: Cypress Tests + +on: + pull_request: + branches: + - '*' + push: + branches: + - develop + +jobs: + cypress-run: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Cypress run + uses: cypress-io/github-action@v4 + with: + build: yarn cypress:build + start: yarn start + wait-on: 'http://localhost:5055' + record: true + env: + CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WITH_MIGRATIONS: true + # Fix test titles in cypress dashboard + COMMIT_INFO_MESSAGE: ${{github.event.pull_request.title}} + COMMIT_INFO_SHA: ${{github.event.pull_request.head.sha}} diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 5d104c7c..35ae768b 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -3,7 +3,7 @@ name: Jellyseerr Preview on: push: tags: - - "preview-*" + - 'preview-*' jobs: build_and_push: @@ -16,16 +16,16 @@ jobs: id: get_version run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/} - name: Set up QEMU - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v2 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v2 - name: Log in to Docker Hub - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} - name: Build and push - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v3 with: context: . file: ./Dockerfile diff --git a/.github/workflows/private_registery_push.yml b/.github/workflows/private_registery_push.yml deleted file mode 100644 index 376223c7..00000000 --- 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 66a1ac47..8890dcae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ on: workflow_dispatch jobs: semantic-release: name: Tag and release latest version - runs-on: self-hosted + runs-on: ubuntu-20.04 env: HUSKY: 0 steps: @@ -18,16 +18,14 @@ jobs: with: node-version: 16 - name: Set up QEMU - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v2 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v2 - name: Log in to Docker Hub - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} - - name: Install Yarn - run: npm install -g yarn - name: Install dependencies run: yarn - name: Release @@ -37,6 +35,60 @@ jobs: DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} run: npx semantic-release + build-snap: + name: Build Snap Package (${{ matrix.architecture }}) + needs: semantic-release + runs-on: ubuntu-20.04 + strategy: + fail-fast: false + matrix: + architecture: + - amd64 + - arm64 + - armhf + steps: + - name: Checkout Code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Switch to master branch + run: git checkout master + - name: Pull latest changes + run: git pull + - name: Prepare + id: prepare + run: | + git fetch --prune --tags + if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then + echo ::set-output name=RELEASE::stable + else + echo ::set-output name=RELEASE::edge + fi + - name: Set Up QEMU + uses: docker/setup-qemu-action@v1 + with: + image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde + - name: Build Snap Package + uses: diddlesnaps/snapcraft-multiarch-action@v1 + id: build + with: + architecture: ${{ matrix.architecture }} + - name: Upload Snap Package + uses: actions/upload-artifact@v2 + with: + name: overseerr-snap-package-${{ matrix.architecture }} + path: ${{ steps.build.outputs.snap }} + - name: Review Snap Package + uses: diddlesnaps/snapcraft-review-tools-action@v1 + with: + snap: ${{ steps.build.outputs.snap }} + - name: Publish Snap Package + uses: snapcore/action-publish@v1 + with: + store_login: ${{ secrets.SNAP_LOGIN }} + snap: ${{ steps.build.outputs.snap }} + release: ${{ steps.prepare.outputs.RELEASE }} + discord: name: Send Discord Notification needs: semantic-release @@ -44,7 +96,7 @@ jobs: runs-on: self-hosted steps: - name: Get Build Job Status - uses: technote-space/workflow-conclusion-action@v2 + uses: technote-space/workflow-conclusion-action@v3 - name: Combine Job Status id: status run: | diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml new file mode 100644 index 00000000..bf00e04d --- /dev/null +++ b/.github/workflows/snap.yaml @@ -0,0 +1,88 @@ +name: Publish Snap + +on: + push: + branches: + - develop + +jobs: + jobs: + name: Job Check + runs-on: ubuntu-20.04 + if: "!contains(github.event.head_commit.message, '[skip ci]')" + steps: + - name: Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.10.0 + with: + access_token: ${{ secrets.GITHUB_TOKEN }} + + build-snap: + name: Build Snap Package (${{ matrix.architecture }}) + needs: jobs + runs-on: ubuntu-20.04 + strategy: + fail-fast: false + matrix: + architecture: + - amd64 + - arm64 + - armhf + steps: + - name: Checkout Code + uses: actions/checkout@v3 + - name: Prepare + id: prepare + run: | + git fetch --prune --unshallow --tags + if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then + echo ::set-output name=RELEASE::stable + else + echo ::set-output name=RELEASE::edge + fi + - name: Set Up QEMU + uses: docker/setup-qemu-action@v2 + - name: Build Snap Package + uses: diddlesnaps/snapcraft-multiarch-action@v1 + id: build + with: + architecture: ${{ matrix.architecture }} + - name: Upload Snap Package + uses: actions/upload-artifact@v3 + with: + name: overseerr-snap-package-${{ matrix.architecture }} + path: ${{ steps.build.outputs.snap }} + - name: Review Snap Package + uses: diddlesnaps/snapcraft-review-tools-action@v1 + with: + snap: ${{ steps.build.outputs.snap }} + - name: Publish Snap Package + uses: snapcore/action-publish@v1 + with: + store_login: ${{ secrets.SNAP_LOGIN }} + snap: ${{ steps.build.outputs.snap }} + release: ${{ steps.prepare.outputs.RELEASE }} + + discord: + name: Send Discord Notification + needs: build-snap + if: always() && !contains(github.event.head_commit.message, '[skip ci]') + runs-on: ubuntu-20.04 + steps: + - name: Get Build Job Status + uses: technote-space/workflow-conclusion-action@v3 + - name: Combine Job Status + id: status + run: | + failures=(neutral, skipped, timed_out, action_required) + if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then + echo ::set-output name=status::failure + else + echo ::set-output name=status::$WORKFLOW_CONCLUSION + fi + - name: Post Status to Discord + uses: sarisia/actions-status-discord@v1 + with: + webhook: ${{ secrets.DISCORD_WEBHOOK }} + status: ${{ steps.status.outputs.status }} + title: ${{ github.workflow }} + nofail: true diff --git a/.gitignore b/.gitignore index 41a0481f..70a5d6f2 100644 --- a/.gitignore +++ b/.gitignore @@ -54,5 +54,16 @@ config/db/db.sqlite3-journal # VS Code .vscode/launch.json +# Cypress +cypress.env.json +cypress/videos +cypress/screenshots + +# ESLint +.eslintcache + +# TS Build Info +tsconfig.tsbuildinfo + # Webstorm .idea diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 00000000..c735fffa --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,5 @@ +module.exports = { + plugins: [require('./merged-prettier-plugin.js')], + singleQuote: true, + trailingComma: 'es5', +}; diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 80a16c64..8dc1918f 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -11,9 +11,6 @@ // https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode "esbenp.prettier-vscode", - // https://marketplace.visualstudio.com/items?itemName=eg2.vscode-npm-script - "eg2.vscode-npm-script", - // https://marketplace.visualstudio.com/items?itemName=Orta.vscode-jest "Orta.vscode-jest", diff --git a/.vscode/settings.json b/.vscode/settings.json index 26aca34b..45da7ba6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,8 +15,6 @@ "database": "./config/db/db.sqlite3" } ], - "editor.codeActionsOnSave": { - "source.organizeImports": true - }, - "editor.formatOnSave": true + "editor.formatOnSave": true, + "typescript.preferences.importModuleSpecifier": "non-relative" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 00d4a12e..0c289e5f 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 a016f6d4..96e67c8a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,7 +86,7 @@ When adding new UI text, please try to adhere to the following guidelines: 1. Be concise and clear, and use as few words as possible to make your point. 2. Use the Oxford comma where appropriate. 3. Use the appropriate Unicode characters for ellipses, arrows, and other special characters/symbols. -4. Capitalize proper nouns, such as Plex, Radarr, Sonarr, Telegram, Slack, Pushover, etc. Be sure to also use the official capitalization for any abbreviations; e.g., TMDb and IMDb have a lowercase 'b', whereas TheTVDB has a capital 'B'. +4. Capitalize proper nouns, such as Plex, Radarr, Sonarr, Telegram, Slack, Pushover, etc. Be sure to also use the official capitalization for any abbreviations; e.g., IMDb has a lowercase 'b', whereas TMDB and TheTVDB have a capital 'B'. 5. Title case headings, button text, and form labels. Note that verbs such as "is" should be capitalized, whereas prepositions like "from" should be lowercase (unless as the first or last word of the string, in which case they are also capitalized). 6. Capitalize the first word in validation error messages, dropdowns, and form "tips." These strings should not end in punctuation. 7. Ensure that toast notification strings are complete sentences ending in punctuation. diff --git a/Dockerfile b/Dockerfile index 8f3ed32c..851ba472 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:16.14-alpine AS BUILD_IMAGE +FROM node:16.17-alpine AS BUILD_IMAGE WORKDIR /app @@ -14,7 +14,7 @@ RUN \ esac COPY package.json yarn.lock ./ -RUN yarn install --frozen-lockfile --network-timeout 1000000 +RUN CYPRESS_INSTALL_BINARY=0 yarn install --frozen-lockfile --network-timeout 1000000 COPY . ./ @@ -33,7 +33,7 @@ RUN touch config/DOCKER RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json -FROM node:16.14-alpine +FROM node:16.17-alpine WORKDIR /app diff --git a/Dockerfile.local b/Dockerfile.local index f0228b6b..39e0534f 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -1,4 +1,4 @@ -FROM node:16.14-alpine +FROM node:16.17-alpine COPY . /app WORKDIR /app diff --git a/README.md b/README.md index c195050a..614dcadb 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 00000000..07b0c8b1 --- /dev/null +++ b/cypress.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'cypress'; + +export default defineConfig({ + projectId: 'onnqy3', + e2e: { + baseUrl: 'http://localhost:5055', + experimentalSessionAndOrigin: true, + }, + env: { + ADMIN_EMAIL: 'admin@seerr.dev', + ADMIN_PASSWORD: 'test1234', + USER_EMAIL: 'friend@seerr.dev', + USER_PASSWORD: 'test1234', + }, + retries: { + runMode: 2, + openMode: 0, + }, +}); diff --git a/cypress/config/settings.cypress.json b/cypress/config/settings.cypress.json new file mode 100644 index 00000000..bb7b661b --- /dev/null +++ b/cypress/config/settings.cypress.json @@ -0,0 +1,149 @@ +{ + "clientId": "6919275e-142a-48d8-be6b-93594cbd4626", + "vapidPrivate": "tmnslaO8ZWN6bNbSEv_rolPeBTlNxOwCCAHrM9oZz3M", + "vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs", + "main": { + "apiKey": "testkey", + "applicationTitle": "Overseerr", + "applicationUrl": "", + "csrfProtection": false, + "cacheImages": false, + "defaultPermissions": 32, + "defaultQuotas": { + "movie": {}, + "tv": {} + }, + "hideAvailable": false, + "localLogin": true, + "newPlexLogin": true, + "region": "", + "originalLanguage": "", + "trustProxy": false, + "partialRequestsEnabled": true, + "locale": "en" + }, + "plex": { + "name": "Seerr", + "ip": "192.168.1.1", + "port": 32400, + "useSsl": false, + "libraries": [ + { + "id": "1", + "name": "Movies", + "enabled": true, + "type": "movie" + } + ], + "machineId": "test" + }, + "tautulli": {}, + "radarr": [], + "sonarr": [], + "public": { + "initialized": true + }, + "notifications": { + "agents": { + "email": { + "enabled": false, + "options": { + "emailFrom": "", + "smtpHost": "", + "smtpPort": 587, + "secure": false, + "ignoreTls": false, + "requireTls": false, + "allowSelfSigned": false, + "senderName": "Overseerr" + } + }, + "discord": { + "enabled": false, + "types": 0, + "options": { + "webhookUrl": "", + "enableMentions": true + } + }, + "lunasea": { + "enabled": false, + "types": 0, + "options": { + "webhookUrl": "" + } + }, + "slack": { + "enabled": false, + "types": 0, + "options": { + "webhookUrl": "" + } + }, + "telegram": { + "enabled": false, + "types": 0, + "options": { + "botAPI": "", + "chatId": "", + "sendSilently": false + } + }, + "pushbullet": { + "enabled": false, + "types": 0, + "options": { + "accessToken": "" + } + }, + "pushover": { + "enabled": false, + "types": 0, + "options": { + "accessToken": "", + "userToken": "" + } + }, + "webhook": { + "enabled": false, + "types": 0, + "options": { + "webhookUrl": "", + "jsonPayload": "IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i" + } + }, + "webpush": { + "enabled": false, + "options": {} + }, + "gotify": { + "enabled": false, + "types": 0, + "options": { + "url": "", + "token": "" + } + } + } + }, + "jobs": { + "plex-recently-added-scan": { + "schedule": "0 */5 * * * *" + }, + "plex-full-scan": { + "schedule": "0 0 3 * * *" + }, + "radarr-scan": { + "schedule": "0 0 4 * * *" + }, + "sonarr-scan": { + "schedule": "0 30 4 * * *" + }, + "download-sync": { + "schedule": "0 * * * * *" + }, + "download-sync-reset": { + "schedule": "0 0 1 * * *" + } + } + } diff --git a/cypress/e2e/discover.cy.ts b/cypress/e2e/discover.cy.ts new file mode 100644 index 00000000..3489061b --- /dev/null +++ b/cypress/e2e/discover.cy.ts @@ -0,0 +1,210 @@ +const clickFirstTitleCardInSlider = (sliderTitle: string): void => { + cy.contains('.slider-header', sliderTitle) + .next('[data-testid=media-slider]') + .find('[data-testid=title-card]') + .first() + .trigger('mouseover') + .find('[data-testid=title-card-title]') + .invoke('text') + .then((text) => { + cy.contains('.slider-header', sliderTitle) + .next('[data-testid=media-slider]') + .find('[data-testid=title-card]') + .first() + .click(); + cy.get('[data-testid=media-title]').should('contain', text); + }); +}; + +describe('Discover', () => { + beforeEach(() => { + cy.loginAsAdmin(); + }); + + it('loads a trending item', () => { + cy.intercept('/api/v1/discover/trending*').as('getTrending'); + cy.visit('/'); + cy.wait('@getTrending'); + clickFirstTitleCardInSlider('Trending'); + }); + + it('loads popular movies', () => { + cy.intercept('/api/v1/discover/movies*').as('getPopularMovies'); + cy.visit('/'); + cy.wait('@getPopularMovies'); + clickFirstTitleCardInSlider('Popular Movies'); + }); + + it('loads upcoming movies', () => { + cy.intercept('/api/v1/discover/movies/upcoming*').as('getUpcomingMovies'); + cy.visit('/'); + cy.wait('@getUpcomingMovies'); + clickFirstTitleCardInSlider('Upcoming Movies'); + }); + + it('loads popular series', () => { + cy.intercept('/api/v1/discover/tv*').as('getPopularTv'); + cy.visit('/'); + cy.wait('@getPopularTv'); + clickFirstTitleCardInSlider('Popular Series'); + }); + + it('loads upcoming series', () => { + cy.intercept('/api/v1/discover/tv/upcoming*').as('getUpcomingSeries'); + cy.visit('/'); + cy.wait('@getUpcomingSeries'); + clickFirstTitleCardInSlider('Upcoming Series'); + }); + + it('displays error for media with invalid TMDB ID', () => { + cy.intercept('GET', '/api/v1/media?*', { + pageInfo: { pages: 1, pageSize: 20, results: 1, page: 1 }, + results: [ + { + downloadStatus: [], + downloadStatus4k: [], + id: 1922, + mediaType: 'movie', + tmdbId: 998814, + tvdbId: null, + imdbId: null, + status: 5, + status4k: 1, + createdAt: '2022-08-18T18:11:13.000Z', + updatedAt: '2022-08-18T19:56:41.000Z', + lastSeasonChange: '2022-08-18T19:56:41.000Z', + mediaAddedAt: '2022-08-18T19:56:41.000Z', + serviceId: null, + serviceId4k: null, + externalServiceId: null, + externalServiceId4k: null, + externalServiceSlug: null, + externalServiceSlug4k: null, + ratingKey: null, + ratingKey4k: null, + seasons: [], + }, + ], + }).as('getMedia'); + + cy.visit('/'); + cy.wait('@getMedia'); + cy.contains('.slider-header', 'Recently Added') + .next('[data-testid=media-slider]') + .find('[data-testid=title-card]') + .first() + .find('[data-testid=title-card-title]') + .contains('Movie Not Found'); + }); + + it('displays error for request with invalid TMDB ID', () => { + cy.intercept('GET', '/api/v1/request?*', { + pageInfo: { pages: 1, pageSize: 10, results: 1, page: 1 }, + results: [ + { + id: 582, + status: 1, + createdAt: '2022-08-18T18:11:13.000Z', + updatedAt: '2022-08-18T18:11:13.000Z', + type: 'movie', + is4k: false, + serverId: null, + profileId: null, + rootFolder: null, + languageProfileId: null, + tags: null, + media: { + downloadStatus: [], + downloadStatus4k: [], + id: 1922, + mediaType: 'movie', + tmdbId: 998814, + tvdbId: null, + imdbId: null, + status: 2, + status4k: 1, + createdAt: '2022-08-18T18:11:13.000Z', + updatedAt: '2022-08-18T18:11:13.000Z', + lastSeasonChange: '2022-08-18T18:11:13.000Z', + mediaAddedAt: null, + serviceId: null, + serviceId4k: null, + externalServiceId: null, + externalServiceId4k: null, + externalServiceSlug: null, + externalServiceSlug4k: null, + ratingKey: null, + ratingKey4k: null, + }, + seasons: [], + modifiedBy: null, + requestedBy: { + permissions: 4194336, + id: 18, + email: 'friend@seerr.dev', + plexUsername: null, + username: '', + recoveryLinkExpirationDate: null, + userType: 2, + avatar: + 'https://gravatar.com/avatar/c77fdc27cab83732b8623d2ea873d330?default=mm&size=200', + movieQuotaLimit: null, + movieQuotaDays: null, + tvQuotaLimit: null, + tvQuotaDays: null, + createdAt: '2022-08-17T04:55:28.000Z', + updatedAt: '2022-08-17T04:55:28.000Z', + requestCount: 1, + displayName: 'friend@seerr.dev', + }, + seasonCount: 0, + }, + ], + }).as('getRequests'); + + cy.visit('/'); + cy.wait('@getRequests'); + cy.contains('.slider-header', 'Recent Requests') + .next('[data-testid=media-slider]') + .find('[data-testid=request-card]') + .first() + .find('[data-testid=request-card-title]') + .contains('Movie Not Found'); + }); + + it('loads plex watchlist', () => { + cy.intercept('/api/v1/discover/watchlist', { + fixture: 'watchlist.json', + }).as('getWatchlist'); + // Wait for one of the watchlist movies to resolve + cy.intercept('/api/v1/movie/361743').as('getTmdbMovie'); + + cy.visit('/'); + + cy.wait('@getWatchlist'); + + const sliderHeader = cy.contains('.slider-header', 'Your Plex Watchlist'); + + sliderHeader.scrollIntoView(); + + cy.wait('@getTmdbMovie'); + // Wait a little longer to make sure the movie component reloaded + cy.wait(500); + + sliderHeader + .next('[data-testid=media-slider]') + .find('[data-testid=title-card]') + .first() + .trigger('mouseover') + .find('[data-testid=title-card-title]') + .invoke('text') + .then((text) => { + cy.contains('.slider-header', 'Plex Watchlist') + .next('[data-testid=media-slider]') + .find('[data-testid=title-card]') + .first() + .click(); + cy.get('[data-testid=media-title]').should('contain', text); + }); + }); +}); diff --git a/cypress/e2e/login.cy.ts b/cypress/e2e/login.cy.ts new file mode 100644 index 00000000..1c955417 --- /dev/null +++ b/cypress/e2e/login.cy.ts @@ -0,0 +1,13 @@ +describe('Login Page', () => { + it('succesfully logs in as an admin', () => { + cy.loginAsAdmin(); + cy.visit('/'); + cy.contains('Trending'); + }); + + it('succesfully logs in as a local user', () => { + cy.loginAsUser(); + cy.visit('/'); + cy.contains('Trending'); + }); +}); diff --git a/cypress/e2e/movie-details.cy.ts b/cypress/e2e/movie-details.cy.ts new file mode 100644 index 00000000..1d3ecf3f --- /dev/null +++ b/cypress/e2e/movie-details.cy.ts @@ -0,0 +1,12 @@ +describe('Movie Details', () => { + it('loads a movie page', () => { + cy.loginAsAdmin(); + // Try to load minions: rise of gru + cy.visit('/movie/438148'); + + cy.get('[data-testid=media-title]').should( + 'contain', + 'Minions: The Rise of Gru (2022)' + ); + }); +}); diff --git a/cypress/e2e/pull-to-refresh.cy.ts b/cypress/e2e/pull-to-refresh.cy.ts new file mode 100644 index 00000000..d56c5589 --- /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 00000000..3717f65b --- /dev/null +++ b/cypress/e2e/settings/general-settings.cy.ts @@ -0,0 +1,32 @@ +describe('General Settings', () => { + beforeEach(() => { + cy.loginAsAdmin(); + }); + + it('opens the settings page from the home page', () => { + cy.visit('/'); + + cy.get('[data-testid=sidebar-toggle]').click(); + cy.get('[data-testid=sidebar-menu-settings-mobile]').click(); + + cy.get('.heading').should('contain', 'General Settings'); + }); + + it('modifies setting that requires restart', () => { + cy.visit('/settings'); + + cy.get('#trustProxy').click(); + cy.get('form').submit(); + cy.get('[data-testid=modal-title]').should( + 'contain', + 'Server Restart Required' + ); + + cy.get('[data-testid=modal-ok-button]').click(); + cy.get('[data-testid=modal-title]').should('not.exist'); + + cy.get('[type=checkbox]#trustProxy').click(); + cy.get('form').submit(); + cy.get('[data-testid=modal-title]').should('not.exist'); + }); +}); diff --git a/cypress/e2e/tv-details.cy.ts b/cypress/e2e/tv-details.cy.ts new file mode 100644 index 00000000..5b4bd049 --- /dev/null +++ b/cypress/e2e/tv-details.cy.ts @@ -0,0 +1,28 @@ +describe('TV Details', () => { + it('loads a tv details page', () => { + cy.loginAsAdmin(); + // Try to load stranger things + cy.visit('/tv/66732'); + + cy.get('[data-testid=media-title]').should( + 'contain', + 'Stranger Things (2016)' + ); + }); + + it('shows seasons and expands episodes', () => { + cy.loginAsAdmin(); + + // Try to load stranger things + cy.visit('/tv/66732'); + + // intercept request for season info + cy.intercept('/api/v1/tv/66732/season/4').as('season4'); + + cy.contains('Season 4').should('be.visible').scrollIntoView().click(); + + cy.wait('@season4'); + + cy.contains('Chapter Nine').should('be.visible'); + }); +}); diff --git a/cypress/e2e/user/auto-request-settings.cy.ts b/cypress/e2e/user/auto-request-settings.cy.ts new file mode 100644 index 00000000..e7f5727b --- /dev/null +++ b/cypress/e2e/user/auto-request-settings.cy.ts @@ -0,0 +1,74 @@ +const visitUserEditPage = (email: string): void => { + cy.visit('/users'); + + cy.contains('[data-testid=user-list-row]', email).contains('Edit').click(); +}; + +describe('Auto Request Settings', () => { + beforeEach(() => { + cy.loginAsAdmin(); + }); + + it('should not see watchlist sync settings on an account without permissions', () => { + visitUserEditPage(Cypress.env('USER_EMAIL')); + + cy.contains('Auto-Request Movies').should('not.exist'); + cy.contains('Auto-Request Series').should('not.exist'); + }); + + it('should see watchlist sync settings on an admin account', () => { + visitUserEditPage(Cypress.env('ADMIN_EMAIL')); + + cy.contains('Auto-Request Movies').should('exist'); + cy.contains('Auto-Request Series').should('exist'); + }); + + it('should see auto-request settings after being given permission', () => { + visitUserEditPage(Cypress.env('USER_EMAIL')); + + cy.get('[data-testid=settings-nav-desktop').contains('Permissions').click(); + + cy.get('#autorequest').should('not.be.checked').click(); + + cy.intercept('/api/v1/user/*/settings/permissions').as('userPermissions'); + + cy.contains('Save Changes').click(); + + cy.wait('@userPermissions'); + + cy.reload(); + + cy.get('#autorequest').should('be.checked'); + cy.get('#autorequestmovies').should('be.checked'); + cy.get('#autorequesttv').should('be.checked'); + + cy.get('[data-testid=settings-nav-desktop').contains('General').click(); + + cy.contains('Auto-Request Movies').should('exist'); + cy.contains('Auto-Request Series').should('exist'); + + cy.get('#watchlistSyncMovies').should('not.be.checked').click(); + cy.get('#watchlistSyncTv').should('not.be.checked').click(); + + cy.intercept('/api/v1/user/*/settings/main').as('userMain'); + + cy.contains('Save Changes').click(); + + cy.wait('@userMain'); + + cy.reload(); + + cy.get('#watchlistSyncMovies').should('be.checked').click(); + cy.get('#watchlistSyncTv').should('be.checked').click(); + + cy.contains('Save Changes').click(); + + cy.wait('@userMain'); + + cy.get('[data-testid=settings-nav-desktop').contains('Permissions').click(); + + cy.get('#autorequest').should('be.checked').click(); + + cy.contains('Save Changes').click(); + }); +}); diff --git a/cypress/e2e/user/profile.cy.ts b/cypress/e2e/user/profile.cy.ts new file mode 100644 index 00000000..9cc38d88 --- /dev/null +++ b/cypress/e2e/user/profile.cy.ts @@ -0,0 +1,50 @@ +describe('User Profile', () => { + beforeEach(() => { + cy.loginAsAdmin(); + }); + + it('opens user profile page from the home page', () => { + cy.visit('/'); + + cy.get('[data-testid=user-menu]').click(); + cy.get('[data-testid=user-menu-profile]').click(); + + cy.get('h1').should('contain', Cypress.env('ADMIN_EMAIL')); + }); + + it('loads plex watchlist', () => { + cy.intercept('/api/v1/user/[0-9]*/watchlist', { + fixture: 'watchlist.json', + }).as('getWatchlist'); + // Wait for one of the watchlist movies to resolve + cy.intercept('/api/v1/movie/361743').as('getTmdbMovie'); + + cy.visit('/profile'); + + cy.wait('@getWatchlist'); + + const sliderHeader = cy.contains('.slider-header', 'Plex Watchlist'); + + sliderHeader.scrollIntoView(); + + cy.wait('@getTmdbMovie'); + // Wait a little longer to make sure the movie component reloaded + cy.wait(500); + + sliderHeader + .next('[data-testid=media-slider]') + .find('[data-testid=title-card]') + .first() + .trigger('mouseover') + .find('[data-testid=title-card-title]') + .invoke('text') + .then((text) => { + cy.contains('.slider-header', 'Plex Watchlist') + .next('[data-testid=media-slider]') + .find('[data-testid=title-card]') + .first() + .click(); + cy.get('[data-testid=media-title]').should('contain', text); + }); + }); +}); diff --git a/cypress/e2e/user/user-list.cy.ts b/cypress/e2e/user/user-list.cy.ts new file mode 100644 index 00000000..503bd23f --- /dev/null +++ b/cypress/e2e/user/user-list.cy.ts @@ -0,0 +1,70 @@ +const testUser = { + displayName: 'Test User', + emailAddress: 'test@seeerr.dev', + password: 'test1234', +}; + +describe('User List', () => { + beforeEach(() => { + cy.loginAsAdmin(); + }); + + it('opens the user list from the home page', () => { + cy.visit('/'); + + cy.get('[data-testid=sidebar-toggle]').click(); + cy.get('[data-testid=sidebar-menu-users-mobile]').click(); + + cy.get('[data-testid=page-header]').should('contain', 'User List'); + }); + + it('can find the admin user and friend user in the user list', () => { + cy.visit('/users'); + + cy.get('[data-testid=user-list-row]').contains(Cypress.env('ADMIN_EMAIL')); + cy.get('[data-testid=user-list-row]').contains(Cypress.env('USER_EMAIL')); + }); + + it('can create a local user', () => { + cy.visit('/users'); + + cy.contains('Create Local User').click(); + + cy.get('[data-testid=modal-title]').should('contain', 'Create Local User'); + + cy.get('#displayName').type(testUser.displayName); + cy.get('#email').type(testUser.emailAddress); + cy.get('#password').type(testUser.password); + + cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user'); + + cy.get('[data-testid=modal-ok-button]').click(); + + cy.wait('@user'); + // Wait a little longer for the user list to fully re-render + cy.wait(1000); + + cy.get('[data-testid=user-list-row]').contains(testUser.emailAddress); + }); + + it('can delete the created local test user', () => { + cy.visit('/users'); + + cy.contains('[data-testid=user-list-row]', testUser.emailAddress) + .contains('Delete') + .click(); + + cy.get('[data-testid=modal-title]').should('contain', `Delete User`); + + cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user'); + + cy.get('[data-testid=modal-ok-button]').should('contain', 'Delete').click(); + + cy.wait('@user'); + cy.wait(1000); + + cy.get('[data-testid=user-list-row]') + .contains(testUser.emailAddress) + .should('not.exist'); + }); +}); diff --git a/cypress/fixtures/watchlist.json b/cypress/fixtures/watchlist.json new file mode 100644 index 00000000..896cef74 --- /dev/null +++ b/cypress/fixtures/watchlist.json @@ -0,0 +1,25 @@ +{ + "page": 1, + "totalPages": 1, + "totalResults": 3, + "results": [ + { + "ratingKey": "5d776be17a53e9001e732ab9", + "title": "Top Gun: Maverick", + "mediaType": "movie", + "tmdbId": 361743 + }, + { + "ratingKey": "5e16338fbc1372003ea68ab3", + "title": "Nope", + "mediaType": "movie", + "tmdbId": 762504 + }, + { + "ratingKey": "5f409b8452f200004161e126", + "title": "Hocus Pocus 2", + "mediaType": "movie", + "tmdbId": 642885 + } + ] +} diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts new file mode 100644 index 00000000..0eb9c869 --- /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 00000000..7a7697ca --- /dev/null +++ b/cypress/support/e2e.ts @@ -0,0 +1,7 @@ +import './commands'; + +before(() => { + if (Cypress.env('SEED_DATABASE')) { + cy.exec('yarn cypress:prepare'); + } +}); diff --git a/cypress/support/index.ts b/cypress/support/index.ts new file mode 100644 index 00000000..85706761 --- /dev/null +++ b/cypress/support/index.ts @@ -0,0 +1,14 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +/// + +declare global { + namespace Cypress { + interface Chainable { + login(email?: string, password?: string): Chainable; + loginAsAdmin(): Chainable; + loginAsUser(): Chainable; + } + } +} + +export {}; diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json new file mode 100644 index 00000000..1b6425b8 --- /dev/null +++ b/cypress/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["es5", "dom"], + "types": ["cypress", "node"], + "resolveJsonModule": true, + "esModuleInterop": true + }, + "include": ["**/*.ts"] +} diff --git a/docs/extending-overseerr/reverse-proxy.md b/docs/extending-overseerr/reverse-proxy.md index 5aa6fd46..84752f7c 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 7ff2bcab..e1bacfc6 100644 --- a/docs/extending-overseerr/third-party.md +++ b/docs/extending-overseerr/third-party.md @@ -9,7 +9,7 @@ - [LunaSea](https://docs.lunasea.app/modules/overseerr), a self-hosted controller for mobile and macOS - [Requestrr](https://github.com/darkalfx/requestrr/wiki/Configuring-Overseerr), a Discord chatbot - [Doplarr](https://github.com/kiranshila/Doplarr), a Discord request bot -- [Overseerr Assistant](https://github.com/RemiRigal/Overseerr-Assistant), a browser extension for requesting directly from TMDb and IMDb +- [Overseerr Assistant](https://github.com/RemiRigal/Overseerr-Assistant), a browser extension for requesting directly from TMDB and IMDb - [ha-overseerr](https://github.com/vaparr/ha-overseerr), a custom Home Assistant component - [OverCLIrr](https://github.com/WillFantom/OverCLIrr), a command-line tool - [Overseerr Exporter](https://github.com/WillFantom/overseerr-exporter), a Prometheus exporter diff --git a/docs/support/faq.md b/docs/support/faq.md index 56a17094..c638e863 100644 --- a/docs/support/faq.md +++ b/docs/support/faq.md @@ -45,7 +45,7 @@ Overseerr currently supports the following agents: - New Plex TV - Legacy Plex TV - TheTVDB -- TMDb +- TMDB - [HAMA](https://github.com/ZeroQI/Hama.bundle) Please verify that your library is using one of the agents previously listed. @@ -67,7 +67,7 @@ You can also perform the following to verify the media item has a GUID Overseerr 1. Go to the media item in Plex and **"Get info"** and click on **"View XML"**. 2. Verify that the media item's GUID follows one of the below formats: - 1. TMDb agent `guid="com.plexapp.agents.themoviedb://1705"` + 1. TMDB agent `guid="com.plexapp.agents.themoviedb://1705"` 2. New Plex Movie agent `` 3. TheTVDB agent `guid="com.plexapp.agents.thetvdb://78874/1/1"` 4. Legacy Plex Movie agent `guid="com.plexapp.agents.imdb://tt0765446"` diff --git a/docs/using-overseerr/notifications/webhooks.md b/docs/using-overseerr/notifications/webhooks.md index 37a5c048..cd2ada31 100644 --- a/docs/using-overseerr/notifications/webhooks.md +++ b/docs/using-overseerr/notifications/webhooks.md @@ -81,7 +81,7 @@ These following special variables are only included in media-related notificatio | Variable | Value | | -------------------- | -------------------------------------------------------------------------------------------------------------- | | `{{media_type}}` | The media type (`movie` or `tv`) | -| `{{media_tmdbid}}` | The media's TMDb ID | +| `{{media_tmdbid}}` | The media's TMDB ID | | `{{media_tvdbid}}` | The media's TheTVDB ID | | `{{media_status}}` | The media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) | | `{{media_status4k}}` | The media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) | diff --git a/merged-prettier-plugin.js b/merged-prettier-plugin.js new file mode 100644 index 00000000..6908488f --- /dev/null +++ b/merged-prettier-plugin.js @@ -0,0 +1,21 @@ +/* eslint-disable */ +const tailwind = require('prettier-plugin-tailwindcss'); +const organizeImports = require('prettier-plugin-organize-imports'); + +const combinedFormatter = { + ...tailwind, + parsers: { + ...tailwind.parsers, + ...Object.keys(organizeImports.parsers).reduce((acc, key) => { + acc[key] = { + ...tailwind.parsers[key], + preprocess(code, options) { + return organizeImports.parsers[key].preprocess(code, options); + }, + }; + return acc; + }, {}), + }, +}; + +module.exports = combinedFormatter; diff --git a/next.config.js b/next.config.js index 40d899f7..bf7c7058 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 551f7dd9..33052ad4 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 bb7b7d36..d5fb58f5 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 00000000..bc68da3a --- /dev/null +++ b/renovate.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:js-app", + "group:allNonMajor", + "docker:disableMajor", + "helpers:disableTypesNodeMajor" + ], + "packageRules": [ + { + "matchManagers": ["github-actions"], + "groupName": "GitHub Actions", + "groupSlug": "github-actions" + }, + { + "matchPackageNames": ["node"], + "groupName": "Node.js", + "groupSlug": "node" + } + ] +} diff --git a/server/api/animelist.ts b/server/api/animelist.ts index 20eb2f60..740f6725 100644 --- a/server/api/animelist.ts +++ b/server/api/animelist.ts @@ -1,8 +1,8 @@ +import logger from '@server/logger'; import axios from 'axios'; -import xml2js from 'xml2js'; import fs, { promises as fsp } from 'fs'; import path from 'path'; -import logger from '../logger'; +import xml2js from 'xml2js'; const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds // originally at https://raw.githubusercontent.com/ScudLee/anime-lists/master/anime-list.xml @@ -14,7 +14,7 @@ const LOCAL_PATH = process.env.CONFIG_DIRECTORY const mappingRegexp = new RegExp(/;[0-9]+-([0-9]+)/g); -// Anime-List xml files are community maintained mappings that Hama agent uses to map AniDB IDs to TVDB/TMDb IDs +// Anime-List xml files are community maintained mappings that Hama agent uses to map AniDB IDs to TVDB/TMDB IDs // https://github.com/Anime-Lists/anime-lists/ interface AnimeMapping { diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts index 2a1d9495..cc1e429f 100644 --- a/server/api/externalapi.ts +++ b/server/api/externalapi.ts @@ -1,5 +1,7 @@ -import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; -import NodeCache from 'node-cache'; +import type { AxiosInstance, AxiosRequestConfig } from 'axios'; +import axios from 'axios'; +import rateLimit from 'axios-rate-limit'; +import type NodeCache from 'node-cache'; // 5 minute default TTL (in seconds) const DEFAULT_TTL = 300; @@ -10,6 +12,10 @@ const DEFAULT_ROLLING_BUFFER = 10000; interface ExternalAPIOptions { nodeCache?: NodeCache; headers?: Record; + rateLimit?: { + maxRPS: number; + maxRequests: number; + }; } class ExternalAPI { @@ -31,6 +37,14 @@ class ExternalAPI { ...options.headers, }, }); + + if (options.rateLimit) { + this.axios = rateLimit(this.axios, { + maxRequests: options.rateLimit.maxRequests, + maxRPS: options.rateLimit.maxRPS, + }); + } + this.baseUrl = baseUrl; this.cache = options.nodeCache; } diff --git a/server/api/github.ts b/server/api/github.ts index a2a71b41..86539903 100644 --- a/server/api/github.ts +++ b/server/api/github.ts @@ -1,5 +1,5 @@ -import cacheManager from '../lib/cache'; -import logger from '../logger'; +import cacheManager from '@server/lib/cache'; +import logger from '@server/logger'; import ExternalAPI from './externalapi'; interface GitHubRelease { diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index 5dd258c4..79b0778a 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 73278387..03246c81 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 1733a85a..76ee6618 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -1,8 +1,9 @@ -import axios, { AxiosInstance } from 'axios'; +import type { PlexDevice } from '@server/interfaces/api/plexInterfaces'; +import cacheManager from '@server/lib/cache'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; import xml2js from 'xml2js'; -import { PlexDevice } from '../interfaces/api/plexInterfaces'; -import { getSettings } from '../lib/settings'; -import logger from '../logger'; +import ExternalAPI from './externalapi'; interface PlexAccountResponse { user: PlexUser; @@ -111,20 +112,54 @@ interface UsersResponse { }; } -class PlexTvAPI { +interface WatchlistResponse { + MediaContainer: { + totalSize: number; + Metadata?: { + ratingKey: string; + }[]; + }; +} + +interface MetadataResponse { + MediaContainer: { + Metadata: { + ratingKey: string; + type: 'movie' | 'show'; + title: string; + Guid: { + id: `imdb://tt${number}` | `tmdb://${number}` | `tvdb://${number}`; + }[]; + }[]; + }; +} + +export interface PlexWatchlistItem { + ratingKey: string; + tmdbId: number; + tvdbId?: number; + type: 'movie' | 'show'; + title: string; +} + +class PlexTvAPI extends ExternalAPI { private authToken: string; - private axios: AxiosInstance; constructor(authToken: string) { + super( + 'https://plex.tv', + {}, + { + headers: { + 'X-Plex-Token': authToken, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + nodeCache: cacheManager.getCache('plextv').data, + } + ); + this.authToken = authToken; - this.axios = axios.create({ - baseURL: 'https://plex.tv', - headers: { - 'X-Plex-Token': this.authToken, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - }); } public async getDevices(): Promise { @@ -252,6 +287,83 @@ class PlexTvAPI { )) as UsersResponse; return parsedXml; } + + public async getWatchlist({ + offset = 0, + size = 20, + }: { offset?: number; size?: number } = {}): Promise<{ + offset: number; + size: number; + totalSize: number; + items: PlexWatchlistItem[]; + }> { + try { + const response = await this.axios.get( + '/library/sections/watchlist/all', + { + params: { + 'X-Plex-Container-Start': offset, + 'X-Plex-Container-Size': size, + }, + baseURL: 'https://metadata.provider.plex.tv', + } + ); + + const watchlistDetails = await Promise.all( + (response.data.MediaContainer.Metadata ?? []).map( + async (watchlistItem) => { + const detailedResponse = await this.getRolling( + `/library/metadata/${watchlistItem.ratingKey}`, + { + baseURL: 'https://metadata.provider.plex.tv', + } + ); + + const metadata = detailedResponse.MediaContainer.Metadata[0]; + + const tmdbString = metadata.Guid.find((guid) => + guid.id.startsWith('tmdb') + ); + const tvdbString = metadata.Guid.find((guid) => + guid.id.startsWith('tvdb') + ); + + return { + ratingKey: metadata.ratingKey, + // This should always be set? But I guess it also cannot be? + // We will filter out the 0's afterwards + tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0, + tvdbId: tvdbString + ? Number(tvdbString.id.split('//')[1]) + : undefined, + title: metadata.title, + type: metadata.type, + }; + } + ) + ); + + const filteredList = watchlistDetails.filter((detail) => detail.tmdbId); + + return { + offset, + size, + totalSize: response.data.MediaContainer.totalSize, + items: filteredList, + }; + } catch (e) { + logger.error('Failed to retrieve watchlist items', { + label: 'Plex.TV Metadata API', + errorMessage: e.message, + }); + return { + offset, + size, + totalSize: 0, + items: [], + }; + } + } } export default PlexTvAPI; diff --git a/server/api/rottentomatoes.ts b/server/api/rottentomatoes.ts index b9b00e10..e190b7b9 100644 --- a/server/api/rottentomatoes.ts +++ b/server/api/rottentomatoes.ts @@ -1,4 +1,4 @@ -import cacheManager from '../lib/cache'; +import cacheManager from '@server/lib/cache'; import ExternalAPI from './externalapi'; interface RTSearchResult { diff --git a/server/api/servarr/base.ts b/server/api/servarr/base.ts index 9e455933..2b8ec4cb 100644 --- a/server/api/servarr/base.ts +++ b/server/api/servarr/base.ts @@ -1,6 +1,7 @@ -import cacheManager, { AvailableCacheIds } from '../../lib/cache'; -import { DVRSettings } from '../../lib/settings'; -import ExternalAPI from '../externalapi'; +import ExternalAPI from '@server/api/externalapi'; +import type { AvailableCacheIds } from '@server/lib/cache'; +import cacheManager from '@server/lib/cache'; +import type { DVRSettings } from '@server/lib/settings'; export interface SystemStatus { version: string; diff --git a/server/api/servarr/radarr.ts b/server/api/servarr/radarr.ts index 7305baf0..1637a8d8 100644 --- a/server/api/servarr/radarr.ts +++ b/server/api/servarr/radarr.ts @@ -1,4 +1,4 @@ -import logger from '../../logger'; +import logger from '@server/logger'; import ServarrBase from './base'; export interface RadarrMovieOptions { @@ -69,7 +69,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { return response.data[0]; } catch (e) { - logger.error('Error retrieving movie by TMDb ID', { + logger.error('Error retrieving movie by TMDB ID', { label: 'Radarr API', errorMessage: e.message, tmdbId: id, diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index 7440d278..a5b9c1e8 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -1,4 +1,4 @@ -import logger from '../../logger'; +import logger from '@server/logger'; import ServarrBase from './base'; interface SonarrSeason { diff --git a/server/api/tautulli.ts b/server/api/tautulli.ts index bb7f3723..0e5e0707 100644 --- a/server/api/tautulli.ts +++ b/server/api/tautulli.ts @@ -1,8 +1,9 @@ -import axios, { AxiosInstance } from 'axios'; +import type { User } from '@server/entity/User'; +import type { TautulliSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import type { AxiosInstance } from 'axios'; +import axios from 'axios'; import { uniqWith } from 'lodash'; -import { User } from '../entity/User'; -import { TautulliSettings } from '../lib/settings'; -import logger from '../logger'; export interface TautulliHistoryRecord { date: number; diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index b5060c03..ea05b8ab 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -1,7 +1,7 @@ +import ExternalAPI from '@server/api/externalapi'; +import cacheManager from '@server/lib/cache'; import { sortBy } from 'lodash'; -import cacheManager from '../../lib/cache'; -import ExternalAPI from '../externalapi'; -import { +import type { TmdbCollection, TmdbExternalIdResponse, TmdbGenre, @@ -92,6 +92,10 @@ class TheMovieDb extends ExternalAPI { }, { nodeCache: cacheManager.getCache('tmdb').data, + rateLimit: { + maxRequests: 20, + maxRPS: 50, + }, } ); this.region = region; @@ -192,7 +196,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch person details: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`); } }; @@ -214,7 +218,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { throw new Error( - `[TMDb] Failed to fetch person combined credits: ${e.message}` + `[TMDB] Failed to fetch person combined credits: ${e.message}` ); } }; @@ -241,7 +245,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch movie details: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`); } }; @@ -267,7 +271,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`); } }; @@ -293,7 +297,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`); } }; @@ -319,7 +323,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); } } @@ -345,7 +349,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); } } @@ -371,7 +375,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch movies by keyword: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`); } } @@ -398,7 +402,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { throw new Error( - `[TMDb] Failed to fetch TV recommendations: ${e.message}` + `[TMDB] Failed to fetch TV recommendations: ${e.message}` ); } } @@ -422,7 +426,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch TV similar: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch TV similar: ${e.message}`); } } @@ -455,7 +459,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); } }; @@ -488,7 +492,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch discover TV: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch discover TV: ${e.message}`); } }; @@ -514,7 +518,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch upcoming movies: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`); } }; @@ -541,7 +545,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); } }; @@ -564,7 +568,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); } }; @@ -587,7 +591,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); } }; @@ -619,7 +623,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to find by external ID: ${e.message}`); + throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`); } } @@ -657,7 +661,7 @@ class TheMovieDb extends ExternalAPI { throw new Error(`No movie or show returned from API for ID ${imdbId}`); } catch (e) { throw new Error( - `[TMDb] Failed to find media using external IMDb ID: ${e.message}` + `[TMDB] Failed to find media using external IMDb ID: ${e.message}` ); } } @@ -687,7 +691,7 @@ class TheMovieDb extends ExternalAPI { throw new Error(`No show returned from API for ID ${tvdbId}`); } catch (e) { throw new Error( - `[TMDb] Failed to get TV show using the external TVDB ID: ${e.message}` + `[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}` ); } } @@ -711,7 +715,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch collection: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`); } } @@ -727,7 +731,7 @@ class TheMovieDb extends ExternalAPI { return regions; } catch (e) { - throw new Error(`[TMDb] Failed to fetch countries: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch countries: ${e.message}`); } } @@ -743,7 +747,7 @@ class TheMovieDb extends ExternalAPI { return languages; } catch (e) { - throw new Error(`[TMDb] Failed to fetch langauges: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch langauges: ${e.message}`); } } @@ -755,7 +759,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch movie studio: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch movie studio: ${e.message}`); } } @@ -765,7 +769,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDb] Failed to fetch TV network: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch TV network: ${e.message}`); } } @@ -816,7 +820,7 @@ class TheMovieDb extends ExternalAPI { return movieGenres; } catch (e) { - throw new Error(`[TMDb] Failed to fetch movie genres: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch movie genres: ${e.message}`); } } @@ -867,7 +871,7 @@ class TheMovieDb extends ExternalAPI { return tvGenres; } catch (e) { - throw new Error(`[TMDb] Failed to fetch TV genres: ${e.message}`); + throw new Error(`[TMDB] Failed to fetch TV genres: ${e.message}`); } } } diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts index 2282fe05..6d005dc9 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 d9ef9e02..de2bf834 100644 --- a/server/constants/media.ts +++ b/server/constants/media.ts @@ -2,6 +2,7 @@ export enum MediaRequestStatus { PENDING = 1, APPROVED, DECLINED, + FAILED, } export enum MediaType { diff --git a/ormconfig.js b/server/datasource.ts similarity index 64% rename from ormconfig.js rename to server/datasource.ts index 4122f079..a6839298 100644 --- a/ormconfig.js +++ b/server/datasource.ts @@ -1,4 +1,8 @@ -const devConfig = { +import 'reflect-metadata'; +import type { DataSourceOptions, EntityTarget, Repository } from 'typeorm'; +import { DataSource } from 'typeorm'; + +const devConfig: DataSourceOptions = { type: 'sqlite', database: process.env.CONFIG_DIRECTORY ? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3` @@ -10,31 +14,30 @@ const devConfig = { entities: ['server/entity/**/*.ts'], migrations: ['server/migration/**/*.ts'], subscribers: ['server/subscriber/**/*.ts'], - cli: { - entitiesDir: 'server/entity', - migrationsDir: 'server/migration', - }, }; -const prodConfig = { +const prodConfig: DataSourceOptions = { type: 'sqlite', database: process.env.CONFIG_DIRECTORY ? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3` : 'config/db/db.sqlite3', synchronize: false, + migrationsRun: false, logging: false, enableWAL: true, entities: ['dist/entity/**/*.js'], migrations: ['dist/migration/**/*.js'], - migrationsRun: false, subscribers: ['dist/subscriber/**/*.js'], - cli: { - entitiesDir: 'dist/entity', - migrationsDir: 'dist/migration', - }, }; -const finalConfig = - process.env.NODE_ENV !== 'production' ? devConfig : prodConfig; +const dataSource = new DataSource( + process.env.NODE_ENV !== 'production' ? devConfig : prodConfig +); -module.exports = finalConfig; +export const getRepository = ( + target: EntityTarget +): Repository => { + return dataSource.getRepository(target); +}; + +export default dataSource; diff --git a/server/entity/Issue.ts b/server/entity/Issue.ts index d8e05c56..fae96967 100644 --- a/server/entity/Issue.ts +++ b/server/entity/Issue.ts @@ -1,3 +1,5 @@ +import type { IssueType } from '@server/constants/issue'; +import { IssueStatus } from '@server/constants/issue'; import { Column, CreateDateColumn, @@ -7,7 +9,6 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; -import { IssueStatus, IssueType } from '../constants/issue'; import IssueComment from './IssueComment'; import Media from './Media'; import { User } from './User'; diff --git a/server/entity/Media.ts b/server/entity/Media.ts index e0cadeef..48b04c78 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 f7f82115..eefbc11f 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -1,3 +1,23 @@ +import type { RadarrMovieOptions } from '@server/api/servarr/radarr'; +import RadarrAPI from '@server/api/servarr/radarr'; +import type { + AddSeriesOptions, + SonarrSeries, +} from '@server/api/servarr/sonarr'; +import SonarrAPI from '@server/api/servarr/sonarr'; +import TheMovieDb from '@server/api/themoviedb'; +import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; +import { + MediaRequestStatus, + MediaStatus, + MediaType, +} from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces'; +import notificationManager, { Notification } from '@server/lib/notifications'; +import { Permission } from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; import { isEqual, truncate } from 'lodash'; import { AfterInsert, @@ -6,30 +26,347 @@ import { Column, CreateDateColumn, Entity, - getRepository, ManyToOne, OneToMany, PrimaryGeneratedColumn, RelationCount, UpdateDateColumn, } from 'typeorm'; -import RadarrAPI, { RadarrMovieOptions } from '../api/servarr/radarr'; -import SonarrAPI, { - AddSeriesOptions, - SonarrSeries, -} from '../api/servarr/sonarr'; -import TheMovieDb from '../api/themoviedb'; -import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants'; -import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media'; -import notificationManager, { Notification } from '../lib/notifications'; -import { getSettings } from '../lib/settings'; -import logger from '../logger'; import Media from './Media'; import SeasonRequest from './SeasonRequest'; import { User } from './User'; +export class RequestPermissionError extends Error {} +export class QuotaRestrictedError extends Error {} +export class DuplicateMediaRequestError extends Error {} +export class NoSeasonsAvailableError extends Error {} + +type MediaRequestOptions = { + isAutoRequest?: boolean; +}; + @Entity() export class MediaRequest { + public static async request( + requestBody: MediaRequestBody, + user: User, + options: MediaRequestOptions = {} + ): Promise { + const tmdb = new TheMovieDb(); + const mediaRepository = getRepository(Media); + const requestRepository = getRepository(MediaRequest); + const userRepository = getRepository(User); + + let requestUser = user; + + if ( + requestBody.userId && + !requestUser.hasPermission([ + Permission.MANAGE_USERS, + Permission.MANAGE_REQUESTS, + ]) + ) { + throw new RequestPermissionError( + 'You do not have permission to modify the request user.' + ); + } else if (requestBody.userId) { + requestUser = await userRepository.findOneOrFail({ + where: { id: requestBody.userId }, + }); + } + + if (!requestUser) { + throw new Error('User missing from request context.'); + } + + if ( + requestBody.mediaType === MediaType.MOVIE && + !requestUser.hasPermission( + requestBody.is4k + ? [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE] + : [Permission.REQUEST, Permission.REQUEST_MOVIE], + { + type: 'or', + } + ) + ) { + throw new RequestPermissionError( + `You do not have permission to make ${ + requestBody.is4k ? '4K ' : '' + }movie requests.` + ); + } else if ( + requestBody.mediaType === MediaType.TV && + !requestUser.hasPermission( + requestBody.is4k + ? [Permission.REQUEST_4K, Permission.REQUEST_4K_TV] + : [Permission.REQUEST, Permission.REQUEST_TV], + { + type: 'or', + } + ) + ) { + throw new RequestPermissionError( + `You do not have permission to make ${ + requestBody.is4k ? '4K ' : '' + }series requests.` + ); + } + + const quotas = await requestUser.getQuota(); + + if (requestBody.mediaType === MediaType.MOVIE && quotas.movie.restricted) { + throw new QuotaRestrictedError('Movie Quota exceeded.'); + } else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) { + throw new QuotaRestrictedError('Series Quota exceeded.'); + } + + const tmdbMedia = + requestBody.mediaType === MediaType.MOVIE + ? await tmdb.getMovie({ movieId: requestBody.mediaId }) + : await tmdb.getTvShow({ tvId: requestBody.mediaId }); + + let media = await mediaRepository.findOne({ + where: { + tmdbId: requestBody.mediaId, + mediaType: requestBody.mediaType, + }, + relations: ['requests'], + }); + + if (!media) { + media = new Media({ + tmdbId: tmdbMedia.id, + tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id, + status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, + status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, + mediaType: requestBody.mediaType, + }); + } else { + if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) { + media.status = MediaStatus.PENDING; + } + + if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) { + media.status4k = MediaStatus.PENDING; + } + } + + const existing = await requestRepository + .createQueryBuilder('request') + .leftJoin('request.media', 'media') + .leftJoinAndSelect('request.requestedBy', 'user') + .where('request.is4k = :is4k', { is4k: requestBody.is4k }) + .andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id }) + .andWhere('media.mediaType = :mediaType', { + mediaType: requestBody.mediaType, + }) + .getMany(); + + if (existing && existing.length > 0) { + // If there is an existing movie request that isn't declined, don't allow a new one. + if ( + requestBody.mediaType === MediaType.MOVIE && + existing[0].status !== MediaRequestStatus.DECLINED + ) { + logger.warn('Duplicate request for media blocked', { + tmdbId: tmdbMedia.id, + mediaType: requestBody.mediaType, + is4k: requestBody.is4k, + label: 'Media Request', + }); + + throw new DuplicateMediaRequestError( + 'Request for this media already exists.' + ); + } + + // If an existing auto-request for this media exists from the same user, + // don't allow a new one. + if ( + existing.find( + (r) => r.requestedBy.id === requestUser.id && r.isAutoRequest + ) + ) { + throw new DuplicateMediaRequestError( + 'Auto-request for this media and user already exists.' + ); + } + } + + if (requestBody.mediaType === MediaType.MOVIE) { + await mediaRepository.save(media); + + const request = new MediaRequest({ + type: MediaType.MOVIE, + media, + requestedBy: requestUser, + // If the user is an admin or has the "auto approve" permission, automatically approve the request + status: user.hasPermission( + [ + requestBody.is4k + ? Permission.AUTO_APPROVE_4K + : Permission.AUTO_APPROVE, + requestBody.is4k + ? Permission.AUTO_APPROVE_4K_MOVIE + : Permission.AUTO_APPROVE_MOVIE, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, + modifiedBy: user.hasPermission( + [ + requestBody.is4k + ? Permission.AUTO_APPROVE_4K + : Permission.AUTO_APPROVE, + requestBody.is4k + ? Permission.AUTO_APPROVE_4K_MOVIE + : Permission.AUTO_APPROVE_MOVIE, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? user + : undefined, + is4k: requestBody.is4k, + serverId: requestBody.serverId, + profileId: requestBody.profileId, + rootFolder: requestBody.rootFolder, + tags: requestBody.tags, + isAutoRequest: options.isAutoRequest ?? false, + }); + + await requestRepository.save(request); + return request; + } else { + const tmdbMediaShow = tmdbMedia as Awaited< + ReturnType + >; + const requestedSeasons = + requestBody.seasons === 'all' + ? tmdbMediaShow.seasons + .map((season) => season.season_number) + .filter((sn) => sn > 0) + : (requestBody.seasons as number[]); + let existingSeasons: number[] = []; + + // We need to check existing requests on this title to make sure we don't double up on seasons that were + // already requested. In the case they were, we just throw out any duplicates but still approve the request. + // (Unless there are no seasons, in which case we abort) + if (media.requests) { + existingSeasons = media.requests + .filter( + (request) => + request.is4k === requestBody.is4k && + request.status !== MediaRequestStatus.DECLINED + ) + .reduce((seasons, request) => { + const combinedSeasons = request.seasons.map( + (season) => season.seasonNumber + ); + + return [...seasons, ...combinedSeasons]; + }, [] as number[]); + } + + // We should also check seasons that are available/partially available but don't have existing requests + if (media.seasons) { + existingSeasons = [ + ...existingSeasons, + ...media.seasons + .filter( + (season) => + season[requestBody.is4k ? 'status4k' : 'status'] !== + MediaStatus.UNKNOWN + ) + .map((season) => season.seasonNumber), + ]; + } + + const finalSeasons = requestedSeasons.filter( + (rs) => !existingSeasons.includes(rs) + ); + + if (finalSeasons.length === 0) { + throw new NoSeasonsAvailableError('No seasons available to request'); + } else if ( + quotas.tv.limit && + finalSeasons.length > (quotas.tv.remaining ?? 0) + ) { + throw new QuotaRestrictedError('Series Quota exceeded.'); + } + + await mediaRepository.save(media); + + const request = new MediaRequest({ + type: MediaType.TV, + media, + requestedBy: requestUser, + // If the user is an admin or has the "auto approve" permission, automatically approve the request + status: user.hasPermission( + [ + requestBody.is4k + ? Permission.AUTO_APPROVE_4K + : Permission.AUTO_APPROVE, + requestBody.is4k + ? Permission.AUTO_APPROVE_4K_TV + : Permission.AUTO_APPROVE_TV, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, + modifiedBy: user.hasPermission( + [ + requestBody.is4k + ? Permission.AUTO_APPROVE_4K + : Permission.AUTO_APPROVE, + requestBody.is4k + ? Permission.AUTO_APPROVE_4K_TV + : Permission.AUTO_APPROVE_TV, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? user + : undefined, + is4k: requestBody.is4k, + serverId: requestBody.serverId, + profileId: requestBody.profileId, + rootFolder: requestBody.rootFolder, + languageProfileId: requestBody.languageProfileId, + tags: requestBody.tags, + seasons: finalSeasons.map( + (sn) => + new SeasonRequest({ + seasonNumber: sn, + status: user.hasPermission( + [ + requestBody.is4k + ? Permission.AUTO_APPROVE_4K + : Permission.AUTO_APPROVE, + requestBody.is4k + ? Permission.AUTO_APPROVE_4K_TV + : Permission.AUTO_APPROVE_TV, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, + }) + ), + isAutoRequest: options.isAutoRequest ?? false, + }); + + await requestRepository.save(request); + return request; + } + } + @PrimaryGeneratedColumn() public id: number; @@ -120,6 +457,9 @@ export class MediaRequest { }) public tags?: number[]; + @Column({ default: false }) + public isAutoRequest: boolean; + constructor(init?: Partial) { Object.assign(this, init); } @@ -147,6 +487,10 @@ export class MediaRequest { } this.sendNotification(media, Notification.MEDIA_PENDING); + + if (this.isAutoRequest) { + this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED); + } } } @@ -191,6 +535,14 @@ export class MediaRequest { : Notification.MEDIA_APPROVED : Notification.MEDIA_DECLINED ); + + if ( + this.status === MediaRequestStatus.APPROVED && + autoApproved && + this.isAutoRequest + ) { + this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED); + } } } @@ -207,7 +559,7 @@ export class MediaRequest { const mediaRepository = getRepository(Media); const media = await mediaRepository.findOne({ where: { id: this.media.id }, - relations: ['requests'], + relations: { requests: true }, }); if (!media) { logger.error('Media data not found', { @@ -272,7 +624,7 @@ export class MediaRequest { const mediaRepository = getRepository(Media); const fullMedia = await mediaRepository.findOneOrFail({ where: { id: this.media.id }, - relations: ['requests'], + relations: { requests: true }, }); if ( @@ -452,10 +804,13 @@ export class MediaRequest { await mediaRepository.save(media); }) .catch(async () => { - media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; - await mediaRepository.save(media); + const requestRepository = getRepository(MediaRequest); + + this.status = MediaRequestStatus.FAILED; + requestRepository.save(this); + logger.warn( - 'Something went wrong sending movie request to Radarr, marking status as UNKNOWN', + 'Something went wrong sending movie request to Radarr, marking status as FAILED', { label: 'Media Request', requestId: this.id, @@ -543,7 +898,7 @@ export class MediaRequest { const media = await mediaRepository.findOne({ where: { id: this.media.id }, - relations: ['requests'], + relations: { requests: true }, }); if (!media) { @@ -670,7 +1025,7 @@ export class MediaRequest { // We grab media again here to make sure we have the latest version of it const media = await mediaRepository.findOne({ where: { id: this.media.id }, - relations: ['requests'], + relations: { requests: true }, }); if (!media) { @@ -685,10 +1040,13 @@ export class MediaRequest { await mediaRepository.save(media); }) .catch(async () => { - media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; - await mediaRepository.save(media); + const requestRepository = getRepository(MediaRequest); + + this.status = MediaRequestStatus.FAILED; + requestRepository.save(this); + logger.warn( - 'Something went wrong sending series request to Sonarr, marking status as UNKNOWN', + 'Something went wrong sending series request to Sonarr, marking status as FAILED', { label: 'Media Request', requestId: this.id, @@ -723,6 +1081,7 @@ export class MediaRequest { const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series'; let event: string | undefined; let notifyAdmin = true; + let notifySystem = true; switch (type) { case Notification.MEDIA_APPROVED: @@ -736,6 +1095,13 @@ export class MediaRequest { case Notification.MEDIA_PENDING: event = `New ${this.is4k ? '4K ' : ''}${mediaType} Request`; break; + case Notification.MEDIA_AUTO_REQUESTED: + event = `${ + this.is4k ? '4K ' : '' + }${mediaType} Request Automatically Submitted`; + notifyAdmin = false; + notifySystem = false; + break; case Notification.MEDIA_AUTO_APPROVED: event = `${ this.is4k ? '4K ' : '' @@ -752,6 +1118,7 @@ export class MediaRequest { media, request: this, notifyAdmin, + notifySystem, notifyUser: notifyAdmin ? undefined : this.requestedBy, event, subject: `${movie.title}${ @@ -770,6 +1137,7 @@ export class MediaRequest { media, request: this, notifyAdmin, + notifySystem, notifyUser: notifyAdmin ? undefined : this.requestedBy, event, subject: `${tv.name}${ diff --git a/server/entity/Season.ts b/server/entity/Season.ts index 77f9c760..44a83d97 100644 --- a/server/entity/Season.ts +++ b/server/entity/Season.ts @@ -1,12 +1,12 @@ +import { MediaStatus } from '@server/constants/media'; import { - Entity, - PrimaryGeneratedColumn, Column, - ManyToOne, CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; -import { MediaStatus } from '../constants/media'; import Media from './Media'; @Entity() diff --git a/server/entity/SeasonRequest.ts b/server/entity/SeasonRequest.ts index f499406c..f9eeef50 100644 --- a/server/entity/SeasonRequest.ts +++ b/server/entity/SeasonRequest.ts @@ -1,12 +1,12 @@ +import { MediaRequestStatus } from '@server/constants/media'; import { - Entity, - PrimaryGeneratedColumn, Column, CreateDateColumn, - UpdateDateColumn, + Entity, ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, } from 'typeorm'; -import { MediaRequestStatus } from '../constants/media'; import { MediaRequest } from './MediaRequest'; @Entity() diff --git a/server/entity/Session.ts b/server/entity/Session.ts index e7462c19..ddf851a6 100644 --- a/server/entity/Session.ts +++ b/server/entity/Session.ts @@ -1,5 +1,5 @@ -import { ISession } from 'connect-typeorm'; -import { Index, Column, PrimaryColumn, Entity } from 'typeorm'; +import type { ISession } from 'connect-typeorm'; +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; @Entity() export class Session implements ISession { diff --git a/server/entity/User.ts b/server/entity/User.ts index 7fa6dc67..b5f78110 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -1,3 +1,13 @@ +import { MediaRequestStatus, MediaType } from '@server/constants/media'; +import { UserType } from '@server/constants/user'; +import { getRepository } from '@server/datasource'; +import type { QuotaResponse } from '@server/interfaces/api/userInterfaces'; +import PreparedEmail from '@server/lib/email'; +import type { PermissionCheckOptions } from '@server/lib/permissions'; +import { hasPermission, Permission } from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { AfterDate } from '@server/utils/dateHelpers'; import bcrypt from 'bcrypt'; import { randomUUID } from 'crypto'; import path from 'path'; @@ -7,8 +17,6 @@ import { Column, CreateDateColumn, Entity, - getRepository, - MoreThan, Not, OneToMany, OneToOne, @@ -16,17 +24,6 @@ import { RelationCount, UpdateDateColumn, } from 'typeorm'; -import { MediaRequestStatus, MediaType } from '../constants/media'; -import { UserType } from '../constants/user'; -import { QuotaResponse } from '../interfaces/api/userInterfaces'; -import PreparedEmail from '../lib/email'; -import { - hasPermission, - Permission, - PermissionCheckOptions, -} from '../lib/permissions'; -import { getSettings } from '../lib/settings'; -import logger from '../logger'; import Issue from './Issue'; import { MediaRequest } from './MediaRequest'; import SeasonRequest from './SeasonRequest'; @@ -270,13 +267,14 @@ export class User { if (movieQuotaDays) { movieDate.setDate(movieDate.getDate() - movieQuotaDays); } - const movieQuotaStartDate = movieDate.toJSON(); const movieQuotaUsed = movieQuotaLimit ? await requestRepository.count({ where: { - requestedBy: this, - createdAt: MoreThan(movieQuotaStartDate), + requestedBy: { + id: this.id, + }, + createdAt: AfterDate(movieDate), type: MediaType.MOVIE, status: Not(MediaRequestStatus.DECLINED), }, diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index 08397b12..771c382d 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -1,3 +1,6 @@ +import type { NotificationAgentTypes } from '@server/interfaces/api/userSettingsInterfaces'; +import { hasNotificationType, Notification } from '@server/lib/notifications'; +import { NotificationAgentKey } from '@server/lib/settings'; import { Column, Entity, @@ -5,9 +8,6 @@ import { OneToOne, PrimaryGeneratedColumn, } from 'typeorm'; -import { NotificationAgentTypes } from '../interfaces/api/userSettingsInterfaces'; -import { hasNotificationType, Notification } from '../lib/notifications'; -import { NotificationAgentKey } from '../lib/settings'; import { User } from './User'; export const ALL_NOTIFICATIONS = Object.values(Notification) @@ -57,6 +57,12 @@ export class UserSettings { @Column({ nullable: true }) public telegramSendSilently?: boolean; + @Column({ nullable: true }) + public watchlistSyncMovies?: boolean; + + @Column({ nullable: true }) + public watchlistSyncTv?: boolean; + @Column({ type: 'text', nullable: true, diff --git a/server/index.ts b/server/index.ts index c8053012..615e789b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,34 +1,37 @@ +import PlexAPI from '@server/api/plexapi'; +import dataSource, { getRepository } from '@server/datasource'; +import { Session } from '@server/entity/Session'; +import { User } from '@server/entity/User'; +import { startJobs } from '@server/job/schedule'; +import notificationManager from '@server/lib/notifications'; +import DiscordAgent from '@server/lib/notifications/agents/discord'; +import EmailAgent from '@server/lib/notifications/agents/email'; +import GotifyAgent from '@server/lib/notifications/agents/gotify'; +import LunaSeaAgent from '@server/lib/notifications/agents/lunasea'; +import PushbulletAgent from '@server/lib/notifications/agents/pushbullet'; +import PushoverAgent from '@server/lib/notifications/agents/pushover'; +import SlackAgent from '@server/lib/notifications/agents/slack'; +import TelegramAgent from '@server/lib/notifications/agents/telegram'; +import WebhookAgent from '@server/lib/notifications/agents/webhook'; +import WebPushAgent from '@server/lib/notifications/agents/webpush'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import routes from '@server/routes'; +import { getAppVersion } from '@server/utils/appVersion'; +import restartFlag from '@server/utils/restartFlag'; import { getClientIp } from '@supercharge/request-ip'; import { TypeormStore } from 'connect-typeorm/out'; import cookieParser from 'cookie-parser'; import csurf from 'csurf'; -import express, { NextFunction, Request, Response } from 'express'; +import type { NextFunction, Request, Response } from 'express'; +import express from 'express'; import * as OpenApiValidator from 'express-openapi-validator'; -import session, { Store } from 'express-session'; +import type { Store } from 'express-session'; +import session from 'express-session'; import next from 'next'; import path from 'path'; import swaggerUi from 'swagger-ui-express'; -import { createConnection, getRepository } from 'typeorm'; import YAML from 'yamljs'; -import PlexAPI from './api/plexapi'; -import { Session } from './entity/Session'; -import { User } from './entity/User'; -import { startJobs } from './job/schedule'; -import notificationManager from './lib/notifications'; -import DiscordAgent from './lib/notifications/agents/discord'; -import EmailAgent from './lib/notifications/agents/email'; -import GotifyAgent from './lib/notifications/agents/gotify'; -import LunaSeaAgent from './lib/notifications/agents/lunasea'; -import PushbulletAgent from './lib/notifications/agents/pushbullet'; -import PushoverAgent from './lib/notifications/agents/pushover'; -import SlackAgent from './lib/notifications/agents/slack'; -import TelegramAgent from './lib/notifications/agents/telegram'; -import WebhookAgent from './lib/notifications/agents/webhook'; -import WebPushAgent from './lib/notifications/agents/webpush'; -import { getSettings } from './lib/settings'; -import logger from './logger'; -import routes from './routes'; -import { getAppVersion } from './utils/appVersion'; const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml'); @@ -40,7 +43,7 @@ const handle = app.getRequestHandler(); app .prepare() .then(async () => { - const dbConnection = await createConnection(); + const dbConnection = await dataSource.initialize(); // Run migrations in production if (process.env.NODE_ENV === 'production') { @@ -51,6 +54,7 @@ app // Load Settings const settings = getSettings().load(); + restartFlag.initializeSettings(settings.main); // Migrate library types if ( @@ -59,8 +63,8 @@ app ) { const userRepository = getRepository(User); const admin = await userRepository.findOne({ - select: ['id', 'plexToken'], - order: { id: 'ASC' }, + select: { id: true, plexToken: true }, + where: { id: 1 }, }); if (admin) { @@ -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 db90e55d..89cb7426 100644 --- a/server/interfaces/api/discoverInterfaces.ts +++ b/server/interfaces/api/discoverInterfaces.ts @@ -3,3 +3,17 @@ export interface GenreSliderItem { name: string; backdrops: string[]; } + +export interface WatchlistItem { + ratingKey: string; + tmdbId: number; + mediaType: 'movie' | 'tv'; + title: string; +} + +export interface WatchlistResponse { + page: number; + totalPages: number; + totalResults: number; + results: WatchlistItem[]; +} diff --git a/server/interfaces/api/issueInterfaces.ts b/server/interfaces/api/issueInterfaces.ts index bd17f195..e5b3643c 100644 --- a/server/interfaces/api/issueInterfaces.ts +++ b/server/interfaces/api/issueInterfaces.ts @@ -1,5 +1,5 @@ -import Issue from '../../entity/Issue'; -import { PaginatedResponse } from './common'; +import type Issue from '@server/entity/Issue'; +import type { PaginatedResponse } from './common'; export interface IssueResultsResponse extends PaginatedResponse { results: Issue[]; diff --git a/server/interfaces/api/mediaInterfaces.ts b/server/interfaces/api/mediaInterfaces.ts index d17716d2..263d859a 100644 --- a/server/interfaces/api/mediaInterfaces.ts +++ b/server/interfaces/api/mediaInterfaces.ts @@ -1,6 +1,6 @@ -import type Media from '../../entity/Media'; -import { User } from '../../entity/User'; -import { PaginatedResponse } from './common'; +import type Media from '@server/entity/Media'; +import type { User } from '@server/entity/User'; +import type { PaginatedResponse } from './common'; export interface MediaResultsResponse extends PaginatedResponse { results: Media[]; diff --git a/server/interfaces/api/personInterfaces.ts b/server/interfaces/api/personInterfaces.ts index 19d3468c..c52ad0c6 100644 --- a/server/interfaces/api/personInterfaces.ts +++ b/server/interfaces/api/personInterfaces.ts @@ -1,4 +1,4 @@ -import { PersonCreditCast, PersonCreditCrew } from '../../models/Person'; +import type { PersonCreditCast, PersonCreditCrew } from '@server/models/Person'; export interface PersonCombinedCreditsResponse { id: number; diff --git a/server/interfaces/api/plexInterfaces.ts b/server/interfaces/api/plexInterfaces.ts index 5373cb58..32be891e 100644 --- a/server/interfaces/api/plexInterfaces.ts +++ b/server/interfaces/api/plexInterfaces.ts @@ -1,4 +1,4 @@ -import { PlexSettings } from '../../lib/settings'; +import type { PlexSettings } from '@server/lib/settings'; export interface PlexStatus { settings: PlexSettings; diff --git a/server/interfaces/api/requestInterfaces.ts b/server/interfaces/api/requestInterfaces.ts index ca39515b..89863cb0 100644 --- a/server/interfaces/api/requestInterfaces.ts +++ b/server/interfaces/api/requestInterfaces.ts @@ -1,6 +1,21 @@ +import type { MediaType } from '@server/constants/media'; +import type { MediaRequest } from '@server/entity/MediaRequest'; import type { PaginatedResponse } from './common'; -import type { MediaRequest } from '../../entity/MediaRequest'; export interface RequestResultsResponse extends PaginatedResponse { results: MediaRequest[]; } + +export type MediaRequestBody = { + mediaType: MediaType; + mediaId: number; + tvdbId?: number; + seasons?: number[] | 'all'; + is4k?: boolean; + serverId?: number; + profileId?: number; + rootFolder?: string; + languageProfileId?: number; + userId?: number; + tags?: number[]; +}; diff --git a/server/interfaces/api/serviceInterfaces.ts b/server/interfaces/api/serviceInterfaces.ts index 1188f24c..3b430b0b 100644 --- a/server/interfaces/api/serviceInterfaces.ts +++ b/server/interfaces/api/serviceInterfaces.ts @@ -1,5 +1,5 @@ -import { QualityProfile, RootFolder, Tag } from '../../api/servarr/base'; -import { LanguageProfile } from '../../api/servarr/sonarr'; +import type { QualityProfile, RootFolder, Tag } from '@server/api/servarr/base'; +import type { LanguageProfile } from '@server/api/servarr/sonarr'; export interface ServiceCommonServer { id: number; diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index c486a1b4..bafd15b1 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -59,4 +59,5 @@ export interface StatusResponse { commitTag: string; updateAvailable: boolean; commitsBehind: number; + restartRequired: boolean; } diff --git a/server/interfaces/api/userInterfaces.ts b/server/interfaces/api/userInterfaces.ts index e5f56482..2ac75c5e 100644 --- a/server/interfaces/api/userInterfaces.ts +++ b/server/interfaces/api/userInterfaces.ts @@ -1,7 +1,7 @@ -import Media from '../../entity/Media'; -import { MediaRequest } from '../../entity/MediaRequest'; -import type { User } from '../../entity/User'; -import { PaginatedResponse } from './common'; +import type Media from '@server/entity/Media'; +import type { MediaRequest } from '@server/entity/MediaRequest'; +import type { User } from '@server/entity/User'; +import type { PaginatedResponse } from './common'; export interface UserResultsResponse extends PaginatedResponse { results: User[]; @@ -23,6 +23,7 @@ export interface QuotaResponse { movie: QuotaStatus; tv: QuotaStatus; } + export interface UserWatchDataResponse { recentlyWatched: Media[]; playCount: number; diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index d0a0ff9f..e54f0070 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -1,4 +1,4 @@ -import { NotificationAgentKey } from '../../lib/settings'; +import type { NotificationAgentKey } from '@server/lib/settings'; export interface UserSettingsGeneralResponse { username?: string; @@ -15,6 +15,8 @@ export interface UserSettingsGeneralResponse { globalMovieQuotaLimit?: number; globalTvQuotaLimit?: number; globalTvQuotaDays?: number; + watchlistSyncMovies?: boolean; + watchlistSyncTv?: boolean; } export type NotificationAgentTypes = Record; diff --git a/server/job/jellyfinsync/index.ts b/server/job/jellyfinsync/index.ts index 23843d92..85c8dcc5 100644 --- a/server/job/jellyfinsync/index.ts +++ b/server/job/jellyfinsync/index.ts @@ -1,17 +1,19 @@ +import type { JellyfinLibraryItem } from '@server/api/jellyfin'; +import JellyfinAPI from '@server/api/jellyfin'; +import TheMovieDb from '@server/api/themoviedb'; +import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces'; +import { MediaStatus, MediaType } from '@server/constants/media'; +import { MediaServerType } from '@server/constants/server'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import Season from '@server/entity/Season'; +import { User } from '@server/entity/User'; +import type { Library } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import AsyncLock from '@server/utils/asyncLock'; import { randomUUID as uuid } from 'crypto'; import { uniqWith } from 'lodash'; -import { getRepository } from 'typeorm'; -import JellyfinAPI, { JellyfinLibraryItem } from '../../api/jellyfin'; -import TheMovieDb from '../../api/themoviedb'; -import { TmdbTvDetails } from '../../api/themoviedb/interfaces'; -import { MediaStatus, MediaType } from '../../constants/media'; -import { MediaServerType } from '../../constants/server'; -import Media from '../../entity/Media'; -import Season from '../../entity/Season'; -import { User } from '../../entity/User'; -import { getSettings, Library } from '../../lib/settings'; -import logger from '../../logger'; -import AsyncLock from '../../utils/asyncLock'; const BUNDLE_SIZE = 20; const UPDATE_RATE = 4 * 1000; @@ -552,6 +554,7 @@ class JobJellyfinSync { this.running = true; const userRepository = getRepository(User); const admin = await userRepository.findOne({ + where: { id: 1 }, select: [ 'id', 'jellyfinAuthToken', diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 181d540d..356c475e 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 7782a05a..e8146662 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -6,7 +6,8 @@ export type AvailableCacheIds = | 'sonarr' | 'rt' | 'github' - | 'plexguid'; + | 'plexguid' + | 'plextv'; const DEFAULT_TTL = 300; const DEFAULT_CHECK_PERIOD = 120; @@ -58,6 +59,10 @@ class CacheManager { stdTtl: 86400 * 7, // 1 week cache checkPeriod: 60 * 30, }), + plextv: new Cache('plextv', 'Plex TV', { + stdTtl: 86400 * 7, // 1 week cache + checkPeriod: 60, + }), }; public getCache(id: AvailableCacheIds): Cache { diff --git a/server/lib/downloadtracker.ts b/server/lib/downloadtracker.ts index c62e189d..4aef968f 100644 --- a/server/lib/downloadtracker.ts +++ b/server/lib/downloadtracker.ts @@ -1,9 +1,9 @@ +import RadarrAPI from '@server/api/servarr/radarr'; +import SonarrAPI from '@server/api/servarr/sonarr'; +import { MediaType } from '@server/constants/media'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; import { uniqWith } from 'lodash'; -import RadarrAPI from '../api/servarr/radarr'; -import SonarrAPI from '../api/servarr/sonarr'; -import { MediaType } from '../constants/media'; -import logger from '../logger'; -import { getSettings } from './settings'; export interface DownloadingItem { mediaType: MediaType; diff --git a/server/lib/email/index.ts b/server/lib/email/index.ts index 1274d6a8..c38892ae 100644 --- a/server/lib/email/index.ts +++ b/server/lib/email/index.ts @@ -1,7 +1,8 @@ +import type { NotificationAgentEmail } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; import Email from 'email-templates'; import nodemailer from 'nodemailer'; import { URL } from 'url'; -import { getSettings, NotificationAgentEmail } from '../settings'; import { openpgpEncrypt } from './openpgpEncrypt'; class PreparedEmail extends Email { diff --git a/server/lib/email/openpgpEncrypt.ts b/server/lib/email/openpgpEncrypt.ts index c067a7d5..dd320ea3 100644 --- a/server/lib/email/openpgpEncrypt.ts +++ b/server/lib/email/openpgpEncrypt.ts @@ -1,7 +1,8 @@ +import logger from '@server/logger'; import { randomBytes } from 'crypto'; import * as openpgp from 'openpgp'; -import { Transform, TransformCallback } from 'stream'; -import logger from '../../logger'; +import type { TransformCallback } from 'stream'; +import { Transform } from 'stream'; interface EncryptorOptions { signingKey?: string; @@ -26,7 +27,7 @@ class PGPEncryptor extends Transform { // just save the whole message _transform = ( - chunk: any, + chunk: Uint8Array, _encoding: BufferEncoding, callback: TransformCallback ): void => { @@ -184,6 +185,9 @@ class PGPEncryptor extends Transform { } export const openpgpEncrypt = (options: EncryptorOptions) => { + // Disabling this line because I don't want to fix it but I am tired + // of seeing the lint warning + // eslint-disable-next-line @typescript-eslint/no-explicit-any return function (mail: any, callback: () => unknown): void { if (!options.encryptionKeys.length) { setImmediate(callback); diff --git a/server/lib/notifications/agents/agent.ts b/server/lib/notifications/agents/agent.ts index edfa1262..d2b0b165 100644 --- a/server/lib/notifications/agents/agent.ts +++ b/server/lib/notifications/agents/agent.ts @@ -1,14 +1,15 @@ -import { Notification } from '..'; -import type Issue from '../../../entity/Issue'; -import IssueComment from '../../../entity/IssueComment'; -import Media from '../../../entity/Media'; -import { MediaRequest } from '../../../entity/MediaRequest'; -import { User } from '../../../entity/User'; -import { NotificationAgentConfig } from '../../settings'; +import type Issue from '@server/entity/Issue'; +import type IssueComment from '@server/entity/IssueComment'; +import type Media from '@server/entity/Media'; +import type { MediaRequest } from '@server/entity/MediaRequest'; +import type { User } from '@server/entity/User'; +import type { NotificationAgentConfig } from '@server/lib/settings'; +import type { Notification } from '..'; export interface NotificationPayload { event?: string; subject: string; + notifySystem: boolean; notifyAdmin: boolean; notifyUser?: User; media?: Media; diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index 32120035..67a278bf 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -1,19 +1,17 @@ +import { IssueStatus, IssueTypeName } from '@server/constants/issue'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import type { NotificationAgentDiscord } from '@server/lib/settings'; +import { getSettings, NotificationAgentKey } from '@server/lib/settings'; +import logger from '@server/logger'; import axios from 'axios'; -import { getRepository } from 'typeorm'; import { hasNotificationType, Notification, shouldSendAdminNotification, } from '..'; -import { IssueStatus, IssueTypeName } from '../../../constants/issue'; -import { User } from '../../../entity/User'; -import logger from '../../../logger'; -import { - getSettings, - NotificationAgentDiscord, - NotificationAgentKey, -} from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; enum EmbedColors { DEFAULT = 0, @@ -245,7 +243,10 @@ class DiscordAgent ): Promise { const settings = this.getSettings(); - if (!hasNotificationType(type, settings.types ?? 0)) { + if ( + !payload.notifySystem || + !hasNotificationType(type, settings.types ?? 0) + ) { return true; } diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index cbed472f..59c5b4aa 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -1,19 +1,17 @@ -import { EmailOptions } from 'email-templates'; -import path from 'path'; -import { getRepository } from 'typeorm'; -import { Notification, shouldSendAdminNotification } from '..'; -import { IssueType, IssueTypeName } from '../../../constants/issue'; -import { MediaType } from '../../../constants/media'; -import { User } from '../../../entity/User'; -import logger from '../../../logger'; -import PreparedEmail from '../../email'; -import { - getSettings, - NotificationAgentEmail, - NotificationAgentKey, -} from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import { IssueType, IssueTypeName } from '@server/constants/issue'; +import { MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import PreparedEmail from '@server/lib/email'; +import type { NotificationAgentEmail } from '@server/lib/settings'; +import { getSettings, NotificationAgentKey } from '@server/lib/settings'; +import logger from '@server/logger'; +import type { EmailOptions } from 'email-templates'; import * as EmailValidator from 'email-validator'; +import path from 'path'; +import { Notification, shouldSendAdminNotification } from '..'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; class EmailAgent extends BaseAgent @@ -84,6 +82,11 @@ class EmailAgent is4k ? 'in 4K ' : '' }is pending approval:`; break; + case Notification.MEDIA_AUTO_REQUESTED: + body = `A new request for the following ${mediaType} ${ + is4k ? 'in 4K ' : '' + }was automatically submitted:`; + break; case Notification.MEDIA_APPROVED: body = `Your request for the following ${mediaType} ${ is4k ? 'in 4K ' : '' diff --git a/server/lib/notifications/agents/gotify.ts b/server/lib/notifications/agents/gotify.ts index ecd54ce7..d07caac4 100644 --- a/server/lib/notifications/agents/gotify.ts +++ b/server/lib/notifications/agents/gotify.ts @@ -1,15 +1,17 @@ +import { IssueStatus, IssueTypeName } from '@server/constants/issue'; +import type { NotificationAgentGotify } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; import axios from 'axios'; import { hasNotificationType, Notification } from '..'; -import { IssueStatus, IssueTypeName } from '../../../constants/issue'; -import logger from '../../../logger'; -import { getSettings, NotificationAgentGotify } from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; interface GotifyPayload { title: string; message: string; priority: number; - extras: any; + extras: Record; } class GotifyAgent @@ -115,7 +117,10 @@ class GotifyAgent ): Promise { const settings = this.getSettings(); - if (!hasNotificationType(type, settings.types ?? 0)) { + if ( + !payload.notifySystem || + !hasNotificationType(type, settings.types ?? 0) + ) { return true; } diff --git a/server/lib/notifications/agents/lunasea.ts b/server/lib/notifications/agents/lunasea.ts index 0269e260..885b038c 100644 --- a/server/lib/notifications/agents/lunasea.ts +++ b/server/lib/notifications/agents/lunasea.ts @@ -1,10 +1,12 @@ +import { IssueStatus, IssueType } from '@server/constants/issue'; +import { MediaStatus } from '@server/constants/media'; +import type { NotificationAgentLunaSea } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; import axios from 'axios'; import { hasNotificationType, Notification } from '..'; -import { IssueStatus, IssueType } from '../../../constants/issue'; -import { MediaStatus } from '../../../constants/media'; -import logger from '../../../logger'; -import { getSettings, NotificationAgentLunaSea } from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; class LunaSeaAgent extends BaseAgent @@ -85,7 +87,10 @@ class LunaSeaAgent ): Promise { const settings = this.getSettings(); - if (!hasNotificationType(type, settings.types ?? 0)) { + if ( + !payload.notifySystem || + !hasNotificationType(type, settings.types ?? 0) + ) { return true; } diff --git a/server/lib/notifications/agents/pushbullet.ts b/server/lib/notifications/agents/pushbullet.ts index b7bc1919..eed4fda9 100644 --- a/server/lib/notifications/agents/pushbullet.ts +++ b/server/lib/notifications/agents/pushbullet.ts @@ -1,19 +1,18 @@ +import { IssueStatus, IssueTypeName } from '@server/constants/issue'; +import { MediaStatus } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import type { NotificationAgentPushbullet } from '@server/lib/settings'; +import { getSettings, NotificationAgentKey } from '@server/lib/settings'; +import logger from '@server/logger'; import axios from 'axios'; -import { getRepository } from 'typeorm'; import { hasNotificationType, Notification, shouldSendAdminNotification, } from '..'; -import { IssueStatus, IssueTypeName } from '../../../constants/issue'; -import { User } from '../../../entity/User'; -import logger from '../../../logger'; -import { - getSettings, - NotificationAgentKey, - NotificationAgentPushbullet, -} from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; interface PushbulletPayload { type: string; @@ -54,6 +53,12 @@ class PushbulletAgent let status = ''; switch (type) { + case Notification.MEDIA_AUTO_REQUESTED: + status = + payload.media?.status === MediaStatus.PENDING + ? 'Pending Approval' + : 'Processing'; + break; case Notification.MEDIA_PENDING: status = 'Pending Approval'; break; @@ -106,6 +111,7 @@ class PushbulletAgent // Send system notification if ( + payload.notifySystem && hasNotificationType(type, settings.types ?? 0) && settings.enabled && settings.options.accessToken diff --git a/server/lib/notifications/agents/pushover.ts b/server/lib/notifications/agents/pushover.ts index f8364c3f..d8deb1bd 100644 --- a/server/lib/notifications/agents/pushover.ts +++ b/server/lib/notifications/agents/pushover.ts @@ -1,19 +1,18 @@ +import { IssueStatus, IssueTypeName } from '@server/constants/issue'; +import { MediaStatus } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import type { NotificationAgentPushover } from '@server/lib/settings'; +import { getSettings, NotificationAgentKey } from '@server/lib/settings'; +import logger from '@server/logger'; import axios from 'axios'; -import { getRepository } from 'typeorm'; import { hasNotificationType, Notification, shouldSendAdminNotification, } from '..'; -import { IssueStatus, IssueTypeName } from '../../../constants/issue'; -import { User } from '../../../entity/User'; -import logger from '../../../logger'; -import { - getSettings, - NotificationAgentKey, - NotificationAgentPushover, -} from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; interface PushoverPayload { token: string; @@ -63,6 +62,12 @@ class PushoverAgent let status = ''; switch (type) { + case Notification.MEDIA_AUTO_REQUESTED: + status = + payload.media?.status === MediaStatus.PENDING + ? 'Pending Approval' + : 'Processing'; + break; case Notification.MEDIA_PENDING: status = 'Pending Approval'; break; @@ -137,6 +142,7 @@ class PushoverAgent // Send system notification if ( + payload.notifySystem && hasNotificationType(type, settings.types ?? 0) && settings.enabled && settings.options.accessToken && diff --git a/server/lib/notifications/agents/slack.ts b/server/lib/notifications/agents/slack.ts index ca10c269..9447cda3 100644 --- a/server/lib/notifications/agents/slack.ts +++ b/server/lib/notifications/agents/slack.ts @@ -1,9 +1,11 @@ +import { IssueStatus, IssueTypeName } from '@server/constants/issue'; +import type { NotificationAgentSlack } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; import axios from 'axios'; import { hasNotificationType, Notification } from '..'; -import { IssueStatus, IssueTypeName } from '../../../constants/issue'; -import logger from '../../../logger'; -import { getSettings, NotificationAgentSlack } from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; interface EmbedField { type: 'plain_text' | 'mrkdwn'; @@ -223,7 +225,10 @@ class SlackAgent ): Promise { const settings = this.getSettings(); - if (!hasNotificationType(type, settings.types ?? 0)) { + if ( + !payload.notifySystem || + !hasNotificationType(type, settings.types ?? 0) + ) { return true; } diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts index 3450a3c2..7d706212 100644 --- a/server/lib/notifications/agents/telegram.ts +++ b/server/lib/notifications/agents/telegram.ts @@ -1,19 +1,18 @@ +import { IssueStatus, IssueTypeName } from '@server/constants/issue'; +import { MediaStatus } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import type { NotificationAgentTelegram } from '@server/lib/settings'; +import { getSettings, NotificationAgentKey } from '@server/lib/settings'; +import logger from '@server/logger'; import axios from 'axios'; -import { getRepository } from 'typeorm'; import { hasNotificationType, Notification, shouldSendAdminNotification, } from '..'; -import { IssueStatus, IssueTypeName } from '../../../constants/issue'; -import { User } from '../../../entity/User'; -import logger from '../../../logger'; -import { - getSettings, - NotificationAgentKey, - NotificationAgentTelegram, -} from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; interface TelegramMessagePayload { text: string; @@ -81,6 +80,12 @@ class TelegramAgent let status = ''; switch (type) { + case Notification.MEDIA_AUTO_REQUESTED: + status = + payload.media?.status === MediaStatus.PENDING + ? 'Pending Approval' + : 'Processing'; + break; case Notification.MEDIA_PENDING: status = 'Pending Approval'; break; @@ -159,6 +164,7 @@ class TelegramAgent // Send system notification if ( + payload.notifySystem && hasNotificationType(type, settings.types ?? 0) && settings.options.chatId ) { diff --git a/server/lib/notifications/agents/webhook.ts b/server/lib/notifications/agents/webhook.ts index ba2bf5e5..461cd37f 100644 --- a/server/lib/notifications/agents/webhook.ts +++ b/server/lib/notifications/agents/webhook.ts @@ -1,11 +1,13 @@ +import { IssueStatus, IssueType } from '@server/constants/issue'; +import { MediaStatus } from '@server/constants/media'; +import type { NotificationAgentWebhook } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; import axios from 'axios'; import { get } from 'lodash'; import { hasNotificationType, Notification } from '..'; -import { IssueStatus, IssueType } from '../../../constants/issue'; -import { MediaStatus } from '../../../constants/media'; -import logger from '../../../logger'; -import { getSettings, NotificationAgentWebhook } from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; type KeyMapFunction = ( payload: NotificationPayload, @@ -162,7 +164,10 @@ class WebhookAgent ): Promise { const settings = this.getSettings(); - if (!hasNotificationType(type, settings.types ?? 0)) { + if ( + !payload.notifySystem || + !hasNotificationType(type, settings.types ?? 0) + ) { return true; } diff --git a/server/lib/notifications/agents/webpush.ts b/server/lib/notifications/agents/webpush.ts index c87d9496..275a77e8 100644 --- a/server/lib/notifications/agents/webpush.ts +++ b/server/lib/notifications/agents/webpush.ts @@ -1,17 +1,15 @@ -import { getRepository } from 'typeorm'; +import { IssueType, IssueTypeName } from '@server/constants/issue'; +import { MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import { UserPushSubscription } from '@server/entity/UserPushSubscription'; +import type { NotificationAgentConfig } from '@server/lib/settings'; +import { getSettings, NotificationAgentKey } from '@server/lib/settings'; +import logger from '@server/logger'; import webpush from 'web-push'; import { Notification, shouldSendAdminNotification } from '..'; -import { IssueType, IssueTypeName } from '../../../constants/issue'; -import { MediaType } from '../../../constants/media'; -import { User } from '../../../entity/User'; -import { UserPushSubscription } from '../../../entity/UserPushSubscription'; -import logger from '../../../logger'; -import { - getSettings, - NotificationAgentConfig, - NotificationAgentKey, -} from '../../settings'; -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; interface PushNotificationPayload { notificationType: string; @@ -59,6 +57,11 @@ class WebPushAgent case Notification.TEST_NOTIFICATION: message = payload.message; break; + case Notification.MEDIA_AUTO_REQUESTED: + message = `Automatically submitted a new ${ + is4k ? '4K ' : '' + }${mediaType} request.`; + break; case Notification.MEDIA_APPROVED: message = `Your ${ is4k ? '4K ' : '' @@ -160,7 +163,7 @@ class WebPushAgent true) ) { const notifySubs = await userPushSubRepository.find({ - where: { user: payload.notifyUser.id }, + where: { user: { id: payload.notifyUser.id } }, }); pushSubs.push(...notifySubs); diff --git a/server/lib/notifications/index.ts b/server/lib/notifications/index.ts index b8111d02..71aea8fe 100644 --- a/server/lib/notifications/index.ts +++ b/server/lib/notifications/index.ts @@ -1,6 +1,6 @@ -import { User } from '../../entity/User'; -import logger from '../../logger'; -import { Permission } from '../permissions'; +import type { User } from '@server/entity/User'; +import { Permission } from '@server/lib/permissions'; +import logger from '@server/logger'; import type { NotificationAgent, NotificationPayload } from './agents/agent'; export enum Notification { @@ -16,6 +16,7 @@ export enum Notification { ISSUE_COMMENT = 512, ISSUE_RESOLVED = 1024, ISSUE_REOPENED = 2048, + MEDIA_AUTO_REQUESTED = 4096, } export const hasNotificationType = ( diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts index 95160d38..4a4a90d8 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -22,6 +22,11 @@ export enum Permission { MANAGE_ISSUES = 1048576, VIEW_ISSUES = 2097152, CREATE_ISSUES = 4194304, + AUTO_REQUEST = 8388608, + AUTO_REQUEST_MOVIE = 16777216, + AUTO_REQUEST_TV = 33554432, + RECENT_VIEW = 67108864, + WATCHLIST_VIEW = 134217728, } export interface PermissionCheckOptions { diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts index f76ea92b..f0f3db7e 100644 --- a/server/lib/scanners/baseScanner.ts +++ b/server/lib/scanners/baseScanner.ts @@ -1,12 +1,12 @@ +import TheMovieDb from '@server/api/themoviedb'; +import { MediaStatus, MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import Season from '@server/entity/Season'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import AsyncLock from '@server/utils/asyncLock'; import { randomUUID } from 'crypto'; -import { getRepository } from 'typeorm'; -import TheMovieDb from '../../api/themoviedb'; -import { MediaStatus, MediaType } from '../../constants/media'; -import Media from '../../entity/Media'; -import Season from '../../entity/Season'; -import logger from '../../logger'; -import AsyncLock from '../../utils/asyncLock'; -import { getSettings } from '../settings'; // Default scan rates (can be overidden) const BUNDLE_SIZE = 20; @@ -210,7 +210,7 @@ class BaseScanner { } /** - * processShow takes a TMDb ID and an array of ProcessableSeasons, which + * processShow takes a TMDB ID and an array of ProcessableSeasons, which * should include the total episodes a sesaon has + the total available * episodes that each season currently has. Unlike processMovie, this method * does not take an `is4k` option. We handle both the 4k _and_ non 4k status diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts index cd8dbd76..73e4d9b2 100644 --- a/server/lib/scanners/plex/index.ts +++ b/server/lib/scanners/plex/index.ts @@ -1,17 +1,20 @@ -import { uniqWith } from 'lodash'; -import { getRepository } from 'typeorm'; -import animeList from '../../../api/animelist'; -import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../../api/plexapi'; -import { TmdbTvDetails } from '../../../api/themoviedb/interfaces'; -import { User } from '../../../entity/User'; -import cacheManager from '../../cache'; -import { getSettings, Library } from '../../settings'; -import BaseScanner, { +import animeList from '@server/api/animelist'; +import type { PlexLibraryItem, PlexMetadata } from '@server/api/plexapi'; +import PlexAPI from '@server/api/plexapi'; +import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import cacheManager from '@server/lib/cache'; +import type { MediaIds, ProcessableSeason, RunnableScanner, StatusBase, -} from '../baseScanner'; +} from '@server/lib/scanners/baseScanner'; +import BaseScanner from '@server/lib/scanners/baseScanner'; +import type { Library } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import { uniqWith } from 'lodash'; const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/); const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/); @@ -59,8 +62,8 @@ class PlexScanner try { const userRepository = getRepository(User); const admin = await userRepository.findOne({ - select: ['id', 'plexToken'], - order: { id: 'ASC' }, + select: { id: true, plexToken: true }, + where: { id: 1 }, }); if (!admin) { @@ -141,7 +144,9 @@ class PlexScanner 'info' ); } catch (e) { - this.log('Scan interrupted', 'error', { errorMessage: e.message }); + this.log('Scan interrupted', 'error', { + errorMessage: e.message, + }); } finally { this.endRun(sessionId); } @@ -369,7 +374,7 @@ class PlexScanner } }); - // If we got an IMDb ID, but no TMDb ID, lookup the TMDb ID with the IMDb ID + // If we got an IMDb ID, but no TMDB ID, lookup the TMDB ID with the IMDb ID if (mediaIds.imdbId && !mediaIds.tmdbId) { const tmdbMedia = await this.tmdb.getMediaByImdbId({ imdbId: mediaIds.imdbId, @@ -390,7 +395,7 @@ class PlexScanner }); mediaIds.tmdbId = tmdbMedia.id; } - // Check if the agent is TMDb + // Check if the agent is TMDB } else if (plexitem.guid.match(tmdbRegex)) { const tmdbMatch = plexitem.guid.match(tmdbRegex); if (tmdbMatch) { @@ -409,7 +414,7 @@ class PlexScanner mediaIds.tvdbId = Number(matchedtvdb[1]); mediaIds.tmdbId = show.id; } - // Check if the agent (for shows) is TMDb + // Check if the agent (for shows) is TMDB } else if (plexitem.guid.match(tmdbShowRegex)) { const matchedtmdb = plexitem.guid.match(tmdbShowRegex); if (matchedtmdb) { @@ -484,10 +489,10 @@ class PlexScanner } if (!mediaIds.tmdbId) { - throw new Error('Unable to find TMDb ID'); + throw new Error('Unable to find TMDB ID'); } - // We check above if we have the TMDb ID, so we can safely assert the type below + // We check above if we have the TMDB ID, so we can safely assert the type below return mediaIds as MediaIds; } diff --git a/server/lib/scanners/radarr/index.ts b/server/lib/scanners/radarr/index.ts index 5f47b9d9..bc299d7b 100644 --- a/server/lib/scanners/radarr/index.ts +++ b/server/lib/scanners/radarr/index.ts @@ -1,7 +1,13 @@ +import type { RadarrMovie } from '@server/api/servarr/radarr'; +import RadarrAPI from '@server/api/servarr/radarr'; +import type { + RunnableScanner, + StatusBase, +} from '@server/lib/scanners/baseScanner'; +import BaseScanner from '@server/lib/scanners/baseScanner'; +import type { RadarrSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; import { uniqWith } from 'lodash'; -import RadarrAPI, { RadarrMovie } from '../../../api/servarr/radarr'; -import { getSettings, RadarrSettings } from '../../settings'; -import BaseScanner, { RunnableScanner, StatusBase } from '../baseScanner'; type SyncStatus = StatusBase & { currentServer: RadarrSettings; diff --git a/server/lib/scanners/sonarr/index.ts b/server/lib/scanners/sonarr/index.ts index 044f74ec..3256c948 100644 --- a/server/lib/scanners/sonarr/index.ts +++ b/server/lib/scanners/sonarr/index.ts @@ -1,14 +1,17 @@ -import { uniqWith } from 'lodash'; -import { getRepository } from 'typeorm'; -import SonarrAPI, { SonarrSeries } from '../../../api/servarr/sonarr'; -import { TmdbTvDetails } from '../../../api/themoviedb/interfaces'; -import Media from '../../../entity/Media'; -import { getSettings, SonarrSettings } from '../../settings'; -import BaseScanner, { +import type { SonarrSeries } from '@server/api/servarr/sonarr'; +import SonarrAPI from '@server/api/servarr/sonarr'; +import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import type { ProcessableSeason, RunnableScanner, StatusBase, -} from '../baseScanner'; +} from '@server/lib/scanners/baseScanner'; +import BaseScanner from '@server/lib/scanners/baseScanner'; +import type { SonarrSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import { uniqWith } from 'lodash'; type SyncStatus = StatusBase & { currentServer: SonarrSettings; diff --git a/server/lib/search.ts b/server/lib/search.ts index c625f512..be9ee3ae 100644 --- a/server/lib/search.ts +++ b/server/lib/search.ts @@ -1,5 +1,5 @@ -import TheMovieDb from '../api/themoviedb'; -import { +import TheMovieDb from '@server/api/themoviedb'; +import type { TmdbMovieDetails, TmdbMovieResult, TmdbPersonDetails, @@ -9,13 +9,17 @@ import { TmdbSearchTvResponse, TmdbTvDetails, TmdbTvResult, -} from '../api/themoviedb/interfaces'; +} from '@server/api/themoviedb/interfaces'; import { mapMovieDetailsToResult, mapPersonDetailsToResult, mapTvDetailsToResult, -} from '../models/Search'; -import { isMovie, isMovieDetails, isTvDetails } from '../utils/typeHelpers'; +} from '@server/models/Search'; +import { + isMovie, + isMovieDetails, + isTvDetails, +} from '@server/utils/typeHelpers'; interface SearchProvider { pattern: RegExp; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 53fe864c..29e2fcf1 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -1,9 +1,9 @@ +import { MediaServerType } from '@server/constants/server'; import { randomUUID } from 'crypto'; import fs from 'fs'; import { merge } from 'lodash'; import path from 'path'; import webpush from 'web-push'; -import { MediaServerType } from '../constants/server'; import { Permission } from './permissions'; export interface Library { @@ -257,6 +257,7 @@ interface JobSettings { export type JobId = | 'plex-recently-added-scan' | 'plex-full-scan' + | 'plex-watchlist-sync' | 'radarr-scan' | 'sonarr-scan' | 'download-sync' @@ -424,6 +425,9 @@ class Settings { 'plex-full-scan': { schedule: '0 0 3 * * *', }, + 'plex-watchlist-sync': { + schedule: '0 */10 * * * *', + }, 'radarr-scan': { schedule: '0 0 4 * * *', }, diff --git a/server/lib/watchlistsync.ts b/server/lib/watchlistsync.ts new file mode 100644 index 00000000..46147f3f --- /dev/null +++ b/server/lib/watchlistsync.ts @@ -0,0 +1,163 @@ +import PlexTvAPI from '@server/api/plextv'; +import { MediaStatus, MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { + DuplicateMediaRequestError, + MediaRequest, + NoSeasonsAvailableError, + QuotaRestrictedError, + RequestPermissionError, +} from '@server/entity/MediaRequest'; +import { User } from '@server/entity/User'; +import logger from '@server/logger'; +import { Permission } from './permissions'; + +class WatchlistSync { + public async syncWatchlist() { + const userRepository = getRepository(User); + + // Get users who actually have plex tokens + const users = await userRepository + .createQueryBuilder('user') + .addSelect('user.plexToken') + .leftJoinAndSelect('user.settings', 'settings') + .where("user.plexToken != ''") + .getMany(); + + for (const user of users) { + await this.syncUserWatchlist(user); + } + } + + private async syncUserWatchlist(user: User) { + if (!user.plexToken) { + logger.warn('Skipping user watchlist sync for user without plex token', { + label: 'Plex Watchlist Sync', + user: user.displayName, + }); + return; + } + + if ( + !user.hasPermission( + [ + Permission.AUTO_REQUEST, + Permission.AUTO_REQUEST_MOVIE, + Permission.AUTO_APPROVE_TV, + ], + { type: 'or' } + ) + ) { + return; + } + + if ( + !user.settings?.watchlistSyncMovies && + !user.settings?.watchlistSyncTv + ) { + // Skip sync if user settings have it disabled + return; + } + + const plexTvApi = new PlexTvAPI(user.plexToken); + + const response = await plexTvApi.getWatchlist({ size: 200 }); + + const mediaItems = await Media.getRelatedMedia( + response.items.map((i) => i.tmdbId) + ); + + const unavailableItems = response.items.filter( + // If we can find watchlist items in our database that are also available, we should exclude them + (i) => + !mediaItems.find( + (m) => + m.tmdbId === i.tmdbId && + ((m.status !== MediaStatus.UNKNOWN && m.mediaType === 'movie') || + (m.mediaType === 'tv' && m.status === MediaStatus.AVAILABLE)) + ) + ); + + await Promise.all( + unavailableItems.map(async (mediaItem) => { + try { + logger.info("Creating media request from user's Plex Watchlist", { + label: 'Watchlist Sync', + userId: user.id, + mediaTitle: mediaItem.title, + }); + + if (mediaItem.type === 'show' && !mediaItem.tvdbId) { + throw new Error('Missing TVDB ID from Plex Metadata'); + } + + // Check if they have auto-request permissons and watchlist sync + // enabled for the media type + if ( + ((!user.hasPermission( + [Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_MOVIE], + { type: 'or' } + ) || + !user.settings?.watchlistSyncMovies) && + mediaItem.type === 'movie') || + ((!user.hasPermission( + [Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_TV], + { type: 'or' } + ) || + !user.settings?.watchlistSyncTv) && + mediaItem.type === 'show') + ) { + return; + } + + await MediaRequest.request( + { + mediaId: mediaItem.tmdbId, + mediaType: + mediaItem.type === 'show' ? MediaType.TV : MediaType.MOVIE, + seasons: mediaItem.type === 'show' ? 'all' : undefined, + tvdbId: mediaItem.tvdbId, + is4k: false, + }, + user, + { isAutoRequest: true } + ); + } catch (e) { + if (!(e instanceof Error)) { + return; + } + + switch (e.constructor) { + // During watchlist sync, these errors aren't necessarily + // a problem with Overseerr. Since we are auto syncing these constantly, it's + // possible they are unexpectedly at their quota limit, for example. So we'll + // instead log these as debug messages. + case RequestPermissionError: + case DuplicateMediaRequestError: + case QuotaRestrictedError: + case NoSeasonsAvailableError: + logger.debug('Failed to create media request from watchlist', { + label: 'Watchlist Sync', + userId: user.id, + mediaTitle: mediaItem.title, + errorMessage: e.message, + }); + break; + default: + logger.error('Failed to create media request from watchlist', { + label: 'Watchlist Sync', + userId: user.id, + mediaTitle: mediaItem.title, + errorMessage: e.message, + }); + } + } + }) + ); + } +} + +const watchlistSync = new WatchlistSync(); + +export default watchlistSync; diff --git a/server/logger.ts b/server/logger.ts index 4f736e4a..d5809a0e 100644 --- a/server/logger.ts +++ b/server/logger.ts @@ -26,7 +26,7 @@ const hformat = winston.format.printf( ); const logger = winston.createLogger({ - level: process.env.LOG_LEVEL || 'debug', + level: process.env.LOG_LEVEL?.toLowerCase() || 'debug', format: winston.format.combine( winston.format.splat(), winston.format.timestamp(), diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 68869222..326d460d 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -1,11 +1,14 @@ -import { getRepository } from 'typeorm'; -import { User } from '../entity/User'; -import { Permission, PermissionCheckOptions } from '../lib/permissions'; -import { getSettings } from '../lib/settings'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import type { + Permission, + PermissionCheckOptions, +} from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; export const checkUser: Middleware = async (req, _res, next) => { const settings = getSettings(); - let user: User | undefined; + let user: User | undefined | null; if (req.header('X-API-Key') === settings.main.apiKey) { const userRepository = getRepository(User); diff --git a/server/migration/1603944374840-InitialMigration.ts b/server/migration/1603944374840-InitialMigration.ts index 73640565..db71471a 100644 --- a/server/migration/1603944374840-InitialMigration.ts +++ b/server/migration/1603944374840-InitialMigration.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class InitialMigration1603944374840 implements MigrationInterface { name = 'InitialMigration1603944374840'; diff --git a/server/migration/1605085519544-SeasonStatus.ts b/server/migration/1605085519544-SeasonStatus.ts index bcff6f60..059c6bf5 100644 --- a/server/migration/1605085519544-SeasonStatus.ts +++ b/server/migration/1605085519544-SeasonStatus.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class SeasonStatus1605085519544 implements MigrationInterface { name = 'SeasonStatus1605085519544'; diff --git a/server/migration/1606730060700-CascadeMigration.ts b/server/migration/1606730060700-CascadeMigration.ts index 341bc00b..3b1ae070 100644 --- a/server/migration/1606730060700-CascadeMigration.ts +++ b/server/migration/1606730060700-CascadeMigration.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class CascadeMigration1606730060700 implements MigrationInterface { name = 'CascadeMigration1606730060700'; diff --git a/server/migration/1607928251245-DropImdbIdConstraint.ts b/server/migration/1607928251245-DropImdbIdConstraint.ts index 97baa861..f602ea7f 100644 --- a/server/migration/1607928251245-DropImdbIdConstraint.ts +++ b/server/migration/1607928251245-DropImdbIdConstraint.ts @@ -1,4 +1,5 @@ -import { MigrationInterface, QueryRunner, TableUnique } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; +import { TableUnique } from 'typeorm'; export class DropImdbIdConstraint1607928251245 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1608217312474-AddUserRequestDeleteCascades.ts b/server/migration/1608217312474-AddUserRequestDeleteCascades.ts index e2aa8865..622a2c90 100644 --- a/server/migration/1608217312474-AddUserRequestDeleteCascades.ts +++ b/server/migration/1608217312474-AddUserRequestDeleteCascades.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserRequestDeleteCascades1608219049304 implements MigrationInterface diff --git a/server/migration/1608477467935-AddLastSeasonChangeMedia.ts b/server/migration/1608477467935-AddLastSeasonChangeMedia.ts index fba7af7f..e5ab0250 100644 --- a/server/migration/1608477467935-AddLastSeasonChangeMedia.ts +++ b/server/migration/1608477467935-AddLastSeasonChangeMedia.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddLastSeasonChangeMedia1608477467935 implements MigrationInterface diff --git a/server/migration/1608477467936-ForceDropImdbUniqueConstraint.ts b/server/migration/1608477467936-ForceDropImdbUniqueConstraint.ts index 6a109e4d..d54c450e 100644 --- a/server/migration/1608477467936-ForceDropImdbUniqueConstraint.ts +++ b/server/migration/1608477467936-ForceDropImdbUniqueConstraint.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class ForceDropImdbUniqueConstraint1608477467935 implements MigrationInterface diff --git a/server/migration/1609236552057-RemoveTmdbIdUniqueConstraint.ts b/server/migration/1609236552057-RemoveTmdbIdUniqueConstraint.ts index 2cd5415e..50056892 100644 --- a/server/migration/1609236552057-RemoveTmdbIdUniqueConstraint.ts +++ b/server/migration/1609236552057-RemoveTmdbIdUniqueConstraint.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class RemoveTmdbIdUniqueConstraint1609236552057 implements MigrationInterface diff --git a/server/migration/1610070934506-LocalUsers.ts b/server/migration/1610070934506-LocalUsers.ts index 0ece00f4..88b0ae60 100644 --- a/server/migration/1610070934506-LocalUsers.ts +++ b/server/migration/1610070934506-LocalUsers.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class LocalUsers1610070934506 implements MigrationInterface { name = 'LocalUsers1610070934506'; diff --git a/server/migration/1610370640747-Add4kStatusFields.ts b/server/migration/1610370640747-Add4kStatusFields.ts index a313bf13..5502b9c0 100644 --- a/server/migration/1610370640747-Add4kStatusFields.ts +++ b/server/migration/1610370640747-Add4kStatusFields.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class Add4kStatusFields1610370640747 implements MigrationInterface { name = 'Add4kStatusFields1610370640747'; diff --git a/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts b/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts index 25e42a74..d6574d39 100644 --- a/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts +++ b/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddMediaAddedFieldToMedia1610522845513 implements MigrationInterface diff --git a/server/migration/1611508672722-AddDisplayNameToUser.ts b/server/migration/1611508672722-AddDisplayNameToUser.ts index cacea059..6a36f29a 100644 --- a/server/migration/1611508672722-AddDisplayNameToUser.ts +++ b/server/migration/1611508672722-AddDisplayNameToUser.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddDisplayNameToUser1611508672722 implements MigrationInterface { name = 'AddDisplayNameToUser1611508672722'; diff --git a/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts b/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts index 355384a0..5a5b6553 100644 --- a/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts +++ b/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class SonarrRadarrSyncServiceFields1611757511674 implements MigrationInterface diff --git a/server/migration/1611801511397-AddRatingKeysToMedia.ts b/server/migration/1611801511397-AddRatingKeysToMedia.ts index f9865c8f..92ab4d4b 100644 --- a/server/migration/1611801511397-AddRatingKeysToMedia.ts +++ b/server/migration/1611801511397-AddRatingKeysToMedia.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddRatingKeysToMedia1611801511397 implements MigrationInterface { name = 'AddRatingKeysToMedia1611801511397'; diff --git a/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts b/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts index 7d191d10..55a20a39 100644 --- a/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts +++ b/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddResetPasswordGuidAndExpiryDate1612482778137 implements MigrationInterface diff --git a/server/migration/1612571545781-AddLanguageProfileId.ts b/server/migration/1612571545781-AddLanguageProfileId.ts index fa89d81b..7694f4e4 100644 --- a/server/migration/1612571545781-AddLanguageProfileId.ts +++ b/server/migration/1612571545781-AddLanguageProfileId.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddLanguageProfileId1612571545781 implements MigrationInterface { name = 'AddLanguageProfileId1612571545781'; diff --git a/server/migration/1613379909641-AddJellyfinUserParams.ts b/server/migration/1613379909641-AddJellyfinUserParams.ts index 46ef3319..b56c873a 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 b8f95053..0fb18f23 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 4d4a973e..fbe85339 100644 --- a/server/migration/1613615266968-CreateUserSettings.ts +++ b/server/migration/1613615266968-CreateUserSettings.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class CreateUserSettings1613615266968 implements MigrationInterface { name = 'CreateUserSettings1613615266968'; diff --git a/server/migration/1613670041760-AddJellyfinDeviceId.ts b/server/migration/1613670041760-AddJellyfinDeviceId.ts index 104b4146..c800f100 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 d33df4ee..69060a0c 100644 --- a/server/migration/1613955393450-UpdateUserSettingsRegions.ts +++ b/server/migration/1613955393450-UpdateUserSettingsRegions.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class UpdateUserSettingsRegions1613955393450 implements MigrationInterface diff --git a/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts b/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts index 5e480d48..6e2598ab 100644 --- a/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts +++ b/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddTelegramSettingsToUserSettings1614334195680 implements MigrationInterface diff --git a/server/migration/1615333940450-AddPGPToUserSettings.ts b/server/migration/1615333940450-AddPGPToUserSettings.ts index b88e0dca..6940d4ad 100644 --- a/server/migration/1615333940450-AddPGPToUserSettings.ts +++ b/server/migration/1615333940450-AddPGPToUserSettings.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddPGPToUserSettings1615333940450 implements MigrationInterface { name = 'AddPGPToUserSettings1615333940450'; diff --git a/server/migration/1616576677254-AddUserQuotaFields.ts b/server/migration/1616576677254-AddUserQuotaFields.ts index 63292690..62b39d65 100644 --- a/server/migration/1616576677254-AddUserQuotaFields.ts +++ b/server/migration/1616576677254-AddUserQuotaFields.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserQuotaFields1616576677254 implements MigrationInterface { name = 'AddUserQuotaFields1616576677254'; diff --git a/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts b/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts index d498a8b1..9e676182 100644 --- a/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts +++ b/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class CreateTagsFieldonMediaRequest1617624225464 implements MigrationInterface diff --git a/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts b/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts index 79cd061b..9dd9288e 100644 --- a/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts +++ b/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserSettingsNotificationAgentsField1617730837489 implements MigrationInterface diff --git a/server/migration/1618912653565-CreateUserPushSubscriptions.ts b/server/migration/1618912653565-CreateUserPushSubscriptions.ts index 539221d1..97070599 100644 --- a/server/migration/1618912653565-CreateUserPushSubscriptions.ts +++ b/server/migration/1618912653565-CreateUserPushSubscriptions.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class CreateUserPushSubscriptions1618912653565 implements MigrationInterface diff --git a/server/migration/1619239659754-AddUserSettingsLocale.ts b/server/migration/1619239659754-AddUserSettingsLocale.ts index 9842bca7..ba182b03 100644 --- a/server/migration/1619239659754-AddUserSettingsLocale.ts +++ b/server/migration/1619239659754-AddUserSettingsLocale.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserSettingsLocale1619239659754 implements MigrationInterface { name = 'AddUserSettingsLocale1619239659754'; diff --git a/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts b/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts index cccdae2f..50de959b 100644 --- a/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts +++ b/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserSettingsNotificationTypes1619339817343 implements MigrationInterface diff --git a/server/migration/1634904083966-AddIssues.ts b/server/migration/1634904083966-AddIssues.ts index 0c6116f9..ebcf8d89 100644 --- a/server/migration/1634904083966-AddIssues.ts +++ b/server/migration/1634904083966-AddIssues.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddIssues1634904083966 implements MigrationInterface { name = 'AddIssues1634904083966'; diff --git a/server/migration/1635079863457-AddPushbulletPushoverUserSettings.ts b/server/migration/1635079863457-AddPushbulletPushoverUserSettings.ts index 8934866f..c29cef6d 100644 --- a/server/migration/1635079863457-AddPushbulletPushoverUserSettings.ts +++ b/server/migration/1635079863457-AddPushbulletPushoverUserSettings.ts @@ -1,4 +1,4 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddPushbulletPushoverUserSettings1635079863457 implements MigrationInterface diff --git a/server/migration/1660632269368-AddWatchlistSyncUserSetting.ts b/server/migration/1660632269368-AddWatchlistSyncUserSetting.ts new file mode 100644 index 00000000..c0d0e947 --- /dev/null +++ b/server/migration/1660632269368-AddWatchlistSyncUserSetting.ts @@ -0,0 +1,33 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddWatchlistSyncUserSetting1660632269368 + implements MigrationInterface +{ + name = 'AddWatchlistSyncUserSetting1660632269368'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey" FROM "user_settings"` + ); + await queryRunner.query(`DROP TABLE "user_settings"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` + ); + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + } +} diff --git a/server/migration/1660714479373-AddMediaRequestIsAutoRequestedField.ts b/server/migration/1660714479373-AddMediaRequestIsAutoRequestedField.ts new file mode 100644 index 00000000..8580bb4e --- /dev/null +++ b/server/migration/1660714479373-AddMediaRequestIsAutoRequestedField.ts @@ -0,0 +1,33 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddMediaRequestIsAutoRequestedField1660714479373 + implements MigrationInterface +{ + name = 'AddMediaRequestIsAutoRequestedField1660714479373'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT (0), CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags" FROM "media_request"` + ); + await queryRunner.query(`DROP TABLE "media_request"`); + await queryRunner.query( + `ALTER TABLE "temporary_media_request" RENAME TO "media_request"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "media_request" RENAME TO "temporary_media_request"` + ); + await queryRunner.query( + `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags" FROM "temporary_media_request"` + ); + await queryRunner.query(`DROP TABLE "temporary_media_request"`); + } +} diff --git a/server/models/Collection.ts b/server/models/Collection.ts index 9cc4f378..20a3c715 100644 --- a/server/models/Collection.ts +++ b/server/models/Collection.ts @@ -1,8 +1,9 @@ +import type { TmdbCollection } from '@server/api/themoviedb/interfaces'; +import { MediaType } from '@server/constants/media'; +import type Media from '@server/entity/Media'; import { sortBy } from 'lodash'; -import type { TmdbCollection } from '../api/themoviedb/interfaces'; -import { MediaType } from '../constants/media'; -import Media from '../entity/Media'; -import { mapMovieResult, MovieResult } from './Search'; +import type { MovieResult } from './Search'; +import { mapMovieResult } from './Search'; export interface Collection { id: number; diff --git a/server/models/Movie.ts b/server/models/Movie.ts index ac19ce7e..a216b743 100644 --- a/server/models/Movie.ts +++ b/server/models/Movie.ts @@ -2,20 +2,22 @@ import type { TmdbMovieDetails, TmdbMovieReleaseResult, TmdbProductionCompany, -} from '../api/themoviedb/interfaces'; -import Media from '../entity/Media'; -import { +} from '@server/api/themoviedb/interfaces'; +import type Media from '@server/entity/Media'; +import type { Cast, Crew, ExternalIds, Genre, + ProductionCompany, + WatchProviders, +} from './common'; +import { mapCast, mapCrew, mapExternalIds, mapVideos, mapWatchProviders, - ProductionCompany, - WatchProviders, } from './common'; export interface Video { diff --git a/server/models/Person.ts b/server/models/Person.ts index 087ab1c7..998585ee 100644 --- a/server/models/Person.ts +++ b/server/models/Person.ts @@ -2,8 +2,8 @@ import type { TmdbPersonCreditCast, TmdbPersonCreditCrew, TmdbPersonDetails, -} from '../api/themoviedb/interfaces'; -import Media from '../entity/Media'; +} from '@server/api/themoviedb/interfaces'; +import type Media from '@server/entity/Media'; export interface PersonDetails { id: number; diff --git a/server/models/Search.ts b/server/models/Search.ts index 73427a37..6ab696fe 100644 --- a/server/models/Search.ts +++ b/server/models/Search.ts @@ -5,9 +5,9 @@ import type { TmdbPersonResult, TmdbTvDetails, TmdbTvResult, -} from '../api/themoviedb/interfaces'; -import { MediaType as MainMediaType } from '../constants/media'; -import Media from '../entity/Media'; +} from '@server/api/themoviedb/interfaces'; +import { MediaType as MainMediaType } from '@server/constants/media'; +import type Media from '@server/entity/Media'; export type MediaType = 'tv' | 'movie' | 'person'; diff --git a/server/models/Tv.ts b/server/models/Tv.ts index b596b1d2..24362b50 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 49e2305c..30b40d98 100644 --- a/server/models/common.ts +++ b/server/models/common.ts @@ -7,8 +7,8 @@ import type { TmdbVideoResult, TmdbWatchProviderDetails, TmdbWatchProviders, -} from '../api/themoviedb/interfaces'; -import { Video } from '../models/Movie'; +} from '@server/api/themoviedb/interfaces'; +import type { Video } from '@server/models/Movie'; export interface ProductionCompany { id: number; diff --git a/server/routes/auth.ts b/server/routes/auth.ts index ba8926a3..35f569fd 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 aa894873..d58b0357 100644 --- a/server/routes/collection.ts +++ b/server/routes/collection.ts @@ -1,8 +1,8 @@ +import TheMovieDb from '@server/api/themoviedb'; +import Media from '@server/entity/Media'; +import logger from '@server/logger'; +import { mapCollection } from '@server/models/Collection'; import { Router } from 'express'; -import TheMovieDb from '../api/themoviedb'; -import Media from '../entity/Media'; -import logger from '../logger'; -import { mapCollection } from '../models/Collection'; const collectionRoutes = Router(); diff --git a/server/routes/discover.ts b/server/routes/discover.ts index ea78bf03..b39a8332 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -1,16 +1,25 @@ +import PlexTvAPI from '@server/api/plextv'; +import TheMovieDb from '@server/api/themoviedb'; +import { MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { User } from '@server/entity/User'; +import type { + GenreSliderItem, + WatchlistResponse, +} from '@server/interfaces/api/discoverInterfaces'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { mapProductionCompany } from '@server/models/Movie'; +import { + mapMovieResult, + mapPersonResult, + mapTvResult, +} from '@server/models/Search'; +import { mapNetwork } from '@server/models/Tv'; +import { isMovie, isPerson } from '@server/utils/typeHelpers'; import { Router } from 'express'; import { sortBy } from 'lodash'; -import TheMovieDb from '../api/themoviedb'; -import { MediaType } from '../constants/media'; -import Media from '../entity/Media'; -import { User } from '../entity/User'; -import { GenreSliderItem } from '../interfaces/api/discoverInterfaces'; -import { getSettings } from '../lib/settings'; -import logger from '../logger'; -import { mapProductionCompany } from '../models/Movie'; -import { mapMovieResult, mapPersonResult, mapTvResult } from '../models/Search'; -import { mapNetwork } from '../models/Tv'; -import { isMovie, isPerson } from '../utils/typeHelpers'; export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => { const settings = getSettings(); @@ -704,4 +713,45 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>( } ); +discoverRoutes.get<{ page?: number }, WatchlistResponse>( + '/watchlist', + async (req, res) => { + const userRepository = getRepository(User); + const itemsPerPage = 20; + const page = req.params.page ?? 1; + const offset = (page - 1) * itemsPerPage; + + const activeUser = await userRepository.findOne({ + where: { id: req.user?.id }, + select: ['id', 'plexToken'], + }); + + if (!activeUser?.plexToken) { + // We will just return an empty array if the user has no Plex token + return res.json({ + page: 1, + totalPages: 1, + totalResults: 0, + results: [], + }); + } + + const plexTV = new PlexTvAPI(activeUser.plexToken); + + const watchlist = await plexTV.getWatchlist({ offset }); + + return res.json({ + page, + totalPages: Math.ceil(watchlist.size / itemsPerPage), + totalResults: watchlist.size, + results: watchlist.items.map((item) => ({ + ratingKey: item.ratingKey, + title: item.title, + mediaType: item.type === 'show' ? 'tv' : 'movie', + tmdbId: item.tmdbId, + })), + }); + } +); + export default discoverRoutes; diff --git a/server/routes/index.ts b/server/routes/index.ts index e2866638..9561e171 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -1,17 +1,22 @@ +import GithubAPI from '@server/api/github'; +import TheMovieDb from '@server/api/themoviedb'; +import type { + TmdbMovieResult, + TmdbTvResult, +} from '@server/api/themoviedb/interfaces'; +import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces'; +import { Permission } from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { checkUser, isAuthenticated } from '@server/middleware/auth'; +import { mapProductionCompany } from '@server/models/Movie'; +import { mapNetwork } from '@server/models/Tv'; +import settingsRoutes from '@server/routes/settings'; +import { appDataPath, appDataStatus } from '@server/utils/appDataVolume'; +import { getAppVersion, getCommitTag } from '@server/utils/appVersion'; +import restartFlag from '@server/utils/restartFlag'; +import { isPerson } from '@server/utils/typeHelpers'; import { Router } from 'express'; -import GithubAPI from '../api/github'; -import TheMovieDb from '../api/themoviedb'; -import { TmdbMovieResult, TmdbTvResult } from '../api/themoviedb/interfaces'; -import { StatusResponse } from '../interfaces/api/settingsInterfaces'; -import { Permission } from '../lib/permissions'; -import { getSettings } from '../lib/settings'; -import logger from '../logger'; -import { checkUser, isAuthenticated } from '../middleware/auth'; -import { mapProductionCompany } from '../models/Movie'; -import { mapNetwork } from '../models/Tv'; -import { appDataPath, appDataStatus } from '../utils/appDataVolume'; -import { getAppVersion, getCommitTag } from '../utils/appVersion'; -import { isPerson } from '../utils/typeHelpers'; import authRoutes from './auth'; import collectionRoutes from './collection'; import discoverRoutes, { createTmdbWithRegionLanguage } from './discover'; @@ -23,7 +28,6 @@ import personRoutes from './person'; import requestRoutes from './request'; import searchRoutes from './search'; import serviceRoutes from './service'; -import settingsRoutes from './settings'; import tvRoutes from './tv'; import user from './user'; @@ -75,6 +79,7 @@ router.get('/status', async (req, res) => { commitTag: getCommitTag(), updateAvailable, commitsBehind, + restartRequired: restartFlag.isSet(), }); }); @@ -97,11 +102,7 @@ router.get('/settings/public', async (req, res) => { return res.status(200).json(settings.fullPublicSettings); } }); -router.use( - '/settings', - isAuthenticated(Permission.MANAGE_SETTINGS), - settingsRoutes -); +router.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes); router.use('/search', isAuthenticated(), searchRoutes); router.use('/discover', isAuthenticated(), discoverRoutes); router.use('/request', isAuthenticated(), requestRoutes); diff --git a/server/routes/issue.ts b/server/routes/issue.ts index 07cf3277..6349bb74 100644 --- a/server/routes/issue.ts +++ b/server/routes/issue.ts @@ -1,13 +1,13 @@ +import { IssueStatus, IssueType } from '@server/constants/issue'; +import { getRepository } from '@server/datasource'; +import Issue from '@server/entity/Issue'; +import IssueComment from '@server/entity/IssueComment'; +import Media from '@server/entity/Media'; +import type { IssueResultsResponse } from '@server/interfaces/api/issueInterfaces'; +import { Permission } from '@server/lib/permissions'; +import logger from '@server/logger'; +import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; -import { getRepository } from 'typeorm'; -import { IssueStatus, IssueType } from '../constants/issue'; -import Issue from '../entity/Issue'; -import IssueComment from '../entity/IssueComment'; -import Media from '../entity/Media'; -import { IssueResultsResponse } from '../interfaces/api/issueInterfaces'; -import { Permission } from '../lib/permissions'; -import logger from '../logger'; -import { isAuthenticated } from '../middleware/auth'; const issueRoutes = Router(); @@ -365,7 +365,7 @@ issueRoutes.delete( try { const issue = await issueRepository.findOneOrFail({ where: { id: Number(req.params.issueId) }, - relations: ['createdBy'], + relations: { createdBy: true }, }); if ( diff --git a/server/routes/issueComment.ts b/server/routes/issueComment.ts index c54bce5b..85e41aaa 100644 --- a/server/routes/issueComment.ts +++ b/server/routes/issueComment.ts @@ -1,9 +1,9 @@ +import { getRepository } from '@server/datasource'; +import IssueComment from '@server/entity/IssueComment'; +import { Permission } from '@server/lib/permissions'; +import logger from '@server/logger'; +import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; -import { getRepository } from 'typeorm'; -import IssueComment from '../entity/IssueComment'; -import { Permission } from '../lib/permissions'; -import logger from '../logger'; -import { isAuthenticated } from '../middleware/auth'; const issueCommentRoutes = Router(); diff --git a/server/routes/media.ts b/server/routes/media.ts index 429b2010..8f93116c 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -1,17 +1,19 @@ -import { Router } from 'express'; -import { FindOneOptions, FindOperator, getRepository, In } from 'typeorm'; -import TautulliAPI from '../api/tautulli'; -import { MediaStatus, MediaType } from '../constants/media'; -import Media from '../entity/Media'; -import { User } from '../entity/User'; -import { +import TautulliAPI from '@server/api/tautulli'; +import { MediaStatus, MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { User } from '@server/entity/User'; +import type { MediaResultsResponse, MediaWatchDataResponse, -} from '../interfaces/api/mediaInterfaces'; -import { Permission } from '../lib/permissions'; -import { getSettings } from '../lib/settings'; -import logger from '../logger'; -import { isAuthenticated } from '../middleware/auth'; +} from '@server/interfaces/api/mediaInterfaces'; +import { Permission } from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { isAuthenticated } from '@server/middleware/auth'; +import { Router } from 'express'; +import type { FindOneOptions } from 'typeorm'; +import { In } from 'typeorm'; const mediaRoutes = Router(); @@ -21,8 +23,7 @@ mediaRoutes.get('/', async (req, res, next) => { const pageSize = req.query.take ? Number(req.query.take) : 20; const skip = req.query.skip ? Number(req.query.skip) : 0; - let statusFilter: MediaStatus | FindOperator | undefined = - undefined; + let statusFilter = undefined; switch (req.query.filter) { case 'available': @@ -66,7 +67,7 @@ mediaRoutes.get('/', async (req, res, next) => { try { const [media, mediaCount] = await mediaRepository.findAndCount({ order: sortFilter, - where: { + where: statusFilter && { status: statusFilter, }, take: pageSize, @@ -151,7 +152,7 @@ mediaRoutes.delete( const mediaRepository = getRepository(Media); const media = await mediaRepository.findOneOrFail({ - where: { id: req.params.id }, + where: { id: Number(req.params.id) }, }); await mediaRepository.remove(media); diff --git a/server/routes/movie.ts b/server/routes/movie.ts index 98474c78..f11cead8 100644 --- a/server/routes/movie.ts +++ b/server/routes/movie.ts @@ -1,11 +1,11 @@ +import RottenTomatoes from '@server/api/rottentomatoes'; +import TheMovieDb from '@server/api/themoviedb'; +import { MediaType } from '@server/constants/media'; +import Media from '@server/entity/Media'; +import logger from '@server/logger'; +import { mapMovieDetails } from '@server/models/Movie'; +import { mapMovieResult } from '@server/models/Search'; import { Router } from 'express'; -import RottenTomatoes from '../api/rottentomatoes'; -import TheMovieDb from '../api/themoviedb'; -import { MediaType } from '../constants/media'; -import Media from '../entity/Media'; -import logger from '../logger'; -import { mapMovieDetails } from '../models/Movie'; -import { mapMovieResult } from '../models/Search'; const movieRoutes = Router(); diff --git a/server/routes/person.ts b/server/routes/person.ts index 5093ae46..009d62af 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 cd269f4e..9c9d96a8 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -1,15 +1,27 @@ +import { + MediaRequestStatus, + MediaStatus, + MediaType, +} from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { + DuplicateMediaRequestError, + MediaRequest, + NoSeasonsAvailableError, + QuotaRestrictedError, + RequestPermissionError, +} from '@server/entity/MediaRequest'; +import SeasonRequest from '@server/entity/SeasonRequest'; +import { User } from '@server/entity/User'; +import type { + MediaRequestBody, + RequestResultsResponse, +} from '@server/interfaces/api/requestInterfaces'; +import { Permission } from '@server/lib/permissions'; +import logger from '@server/logger'; +import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; -import { getRepository } from 'typeorm'; -import TheMovieDb from '../api/themoviedb'; -import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media'; -import Media from '../entity/Media'; -import { MediaRequest } from '../entity/MediaRequest'; -import SeasonRequest from '../entity/SeasonRequest'; -import { User } from '../entity/User'; -import { RequestResultsResponse } from '../interfaces/api/requestInterfaces'; -import { Permission } from '../lib/permissions'; -import logger from '../logger'; -import { isAuthenticated } from '../middleware/auth'; const requestRoutes = Router(); @@ -40,11 +52,15 @@ requestRoutes.get, RequestResultsResponse>( MediaRequestStatus.APPROVED, ]; break; + case 'failed': + statusFilter = [MediaRequestStatus.FAILED]; + break; default: statusFilter = [ MediaRequestStatus.PENDING, MediaRequestStatus.APPROVED, MediaRequestStatus.DECLINED, + MediaRequestStatus.FAILED, ]; } @@ -142,302 +158,38 @@ requestRoutes.get, RequestResultsResponse>( } ); -requestRoutes.post('/', async (req, res, next) => { - const tmdb = new TheMovieDb(); - const mediaRepository = getRepository(Media); - const requestRepository = getRepository(MediaRequest); - const userRepository = getRepository(User); - - try { - let requestUser = req.user; - - if ( - req.body.userId && - !req.user?.hasPermission([ - Permission.MANAGE_USERS, - Permission.MANAGE_REQUESTS, - ]) - ) { - return next({ - status: 403, - message: 'You do not have permission to modify the request user.', - }); - } else if (req.body.userId) { - requestUser = await userRepository.findOneOrFail({ - where: { id: req.body.userId }, - }); - } - - if (!requestUser) { - return next({ - status: 500, - message: 'User missing from request context.', - }); - } - - if ( - req.body.mediaType === MediaType.MOVIE && - !req.user?.hasPermission( - req.body.is4k - ? [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE] - : [Permission.REQUEST, Permission.REQUEST_MOVIE], - { - type: 'or', - } - ) - ) { - return next({ - status: 403, - message: `You do not have permission to make ${ - req.body.is4k ? '4K ' : '' - }movie requests.`, - }); - } else if ( - req.body.mediaType === MediaType.TV && - !req.user?.hasPermission( - req.body.is4k - ? [Permission.REQUEST_4K, Permission.REQUEST_4K_TV] - : [Permission.REQUEST, Permission.REQUEST_TV], - { - type: 'or', - } - ) - ) { - return next({ - status: 403, - message: `You do not have permission to make ${ - req.body.is4k ? '4K ' : '' - }series requests.`, - }); - } - - const quotas = await requestUser.getQuota(); - - if (req.body.mediaType === MediaType.MOVIE && quotas.movie.restricted) { - return next({ - status: 403, - message: 'Movie Quota Exceeded', - }); - } else if (req.body.mediaType === MediaType.TV && quotas.tv.restricted) { - return next({ - status: 403, - message: 'Series Quota Exceeded', - }); - } - - const tmdbMedia = - req.body.mediaType === MediaType.MOVIE - ? await tmdb.getMovie({ movieId: req.body.mediaId }) - : await tmdb.getTvShow({ tvId: req.body.mediaId }); - - let media = await mediaRepository.findOne({ - where: { tmdbId: req.body.mediaId, mediaType: req.body.mediaType }, - relations: ['requests'], - }); - - if (!media) { - media = new Media({ - tmdbId: tmdbMedia.id, - tvdbId: req.body.tvdbId ?? tmdbMedia.external_ids.tvdb_id, - status: !req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, - status4k: req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, - mediaType: req.body.mediaType, - }); - } else { - if (media.status === MediaStatus.UNKNOWN && !req.body.is4k) { - media.status = MediaStatus.PENDING; - } - - if (media.status4k === MediaStatus.UNKNOWN && req.body.is4k) { - media.status4k = MediaStatus.PENDING; - } - } - - if (req.body.mediaType === MediaType.MOVIE) { - const existing = await requestRepository - .createQueryBuilder('request') - .leftJoin('request.media', 'media') - .where('request.is4k = :is4k', { is4k: req.body.is4k }) - .andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id }) - .andWhere('media.mediaType = :mediaType', { - mediaType: MediaType.MOVIE, - }) - .andWhere('request.status != :requestStatus', { - requestStatus: MediaRequestStatus.DECLINED, - }) - .getOne(); - - if (existing) { - logger.warn('Duplicate request for media blocked', { - tmdbId: tmdbMedia.id, - mediaType: req.body.mediaType, - is4k: req.body.is4k, - label: 'Media Request', - }); +requestRoutes.post( + '/', + async (req, res, next) => { + try { + if (!req.user) { return next({ - status: 409, - message: 'Request for this media already exists.', + status: 401, + message: 'You must be logged in to request media.', }); } + const request = await MediaRequest.request(req.body, req.user); - await mediaRepository.save(media); - - const request = new MediaRequest({ - type: MediaType.MOVIE, - media, - requestedBy: requestUser, - // If the user is an admin or has the "auto approve" permission, automatically approve the request - status: req.user?.hasPermission( - [ - req.body.is4k - ? Permission.AUTO_APPROVE_4K - : Permission.AUTO_APPROVE, - req.body.is4k - ? Permission.AUTO_APPROVE_4K_MOVIE - : Permission.AUTO_APPROVE_MOVIE, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } - ) - ? MediaRequestStatus.APPROVED - : MediaRequestStatus.PENDING, - modifiedBy: req.user?.hasPermission( - [ - req.body.is4k - ? Permission.AUTO_APPROVE_4K - : Permission.AUTO_APPROVE, - req.body.is4k - ? Permission.AUTO_APPROVE_4K_MOVIE - : Permission.AUTO_APPROVE_MOVIE, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } - ) - ? req.user - : undefined, - is4k: req.body.is4k, - serverId: req.body.serverId, - profileId: req.body.profileId, - rootFolder: req.body.rootFolder, - tags: req.body.tags, - }); - - await requestRepository.save(request); return res.status(201).json(request); - } else if (req.body.mediaType === MediaType.TV) { - const requestedSeasons = req.body.seasons as number[]; - let existingSeasons: number[] = []; - - // We need to check existing requests on this title to make sure we don't double up on seasons that were - // already requested. In the case they were, we just throw out any duplicates but still approve the request. - // (Unless there are no seasons, in which case we abort) - if (media.requests) { - existingSeasons = media.requests - .filter( - (request) => - request.is4k === req.body.is4k && - request.status !== MediaRequestStatus.DECLINED - ) - .reduce((seasons, request) => { - const combinedSeasons = request.seasons.map( - (season) => season.seasonNumber - ); - - return [...seasons, ...combinedSeasons]; - }, [] as number[]); + } catch (error) { + if (!(error instanceof Error)) { + return; } - const finalSeasons = requestedSeasons.filter( - (rs) => !existingSeasons.includes(rs) - ); - - if (finalSeasons.length === 0) { - return next({ - status: 202, - message: 'No seasons available to request', - }); - } else if ( - quotas.tv.limit && - finalSeasons.length > (quotas.tv.remaining ?? 0) - ) { - return next({ - status: 403, - message: 'Series Quota Exceeded', - }); + switch (error.constructor) { + case RequestPermissionError: + case QuotaRestrictedError: + return next({ status: 403, message: error.message }); + case DuplicateMediaRequestError: + return next({ status: 409, message: error.message }); + case NoSeasonsAvailableError: + return next({ status: 202, message: error.message }); + default: + return next({ status: 500, message: error.message }); } - - await mediaRepository.save(media); - - const request = new MediaRequest({ - type: MediaType.TV, - media, - requestedBy: requestUser, - // If the user is an admin or has the "auto approve" permission, automatically approve the request - status: req.user?.hasPermission( - [ - req.body.is4k - ? Permission.AUTO_APPROVE_4K - : Permission.AUTO_APPROVE, - req.body.is4k - ? Permission.AUTO_APPROVE_4K_TV - : Permission.AUTO_APPROVE_TV, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } - ) - ? MediaRequestStatus.APPROVED - : MediaRequestStatus.PENDING, - modifiedBy: req.user?.hasPermission( - [ - req.body.is4k - ? Permission.AUTO_APPROVE_4K - : Permission.AUTO_APPROVE, - req.body.is4k - ? Permission.AUTO_APPROVE_4K_TV - : Permission.AUTO_APPROVE_TV, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } - ) - ? req.user - : undefined, - is4k: req.body.is4k, - serverId: req.body.serverId, - profileId: req.body.profileId, - rootFolder: req.body.rootFolder, - languageProfileId: req.body.languageProfileId, - tags: req.body.tags, - seasons: finalSeasons.map( - (sn) => - new SeasonRequest({ - seasonNumber: sn, - status: req.user?.hasPermission( - [ - req.body.is4k - ? Permission.AUTO_APPROVE_4K - : Permission.AUTO_APPROVE, - req.body.is4k - ? Permission.AUTO_APPROVE_4K_TV - : Permission.AUTO_APPROVE_TV, - Permission.MANAGE_REQUESTS, - ], - { type: 'or' } - ) - ? MediaRequestStatus.APPROVED - : MediaRequestStatus.PENDING, - }) - ), - }); - - await requestRepository.save(request); - return res.status(201).json(request); } - - next({ status: 500, message: 'Invalid media type' }); - } catch (e) { - next({ status: 500, message: e.message }); } -}); +); requestRoutes.get('/count', async (_req, res, next) => { const requestRepository = getRepository(MediaRequest); @@ -528,7 +280,7 @@ requestRoutes.get('/:requestId', async (req, res, next) => { try { const request = await requestRepository.findOneOrFail({ where: { id: Number(req.params.requestId) }, - relations: ['requestedBy', 'modifiedBy'], + relations: { requestedBy: true, modifiedBy: true }, }); if ( @@ -560,9 +312,11 @@ requestRoutes.put<{ requestId: string }>( const requestRepository = getRepository(MediaRequest); const userRepository = getRepository(User); try { - const request = await requestRepository.findOne( - Number(req.params.requestId) - ); + const request = await requestRepository.findOne({ + where: { + id: Number(req.params.requestId), + }, + }); if (!request) { return next({ status: 404, message: 'Request not found.' }); @@ -628,7 +382,7 @@ requestRoutes.put<{ requestId: string }>( // Get existing media so we can work with all the requests const media = await mediaRepository.findOneOrFail({ where: { tmdbId: request.media.tmdbId, mediaType: MediaType.TV }, - relations: ['requests'], + relations: { requests: true }, }); // Get all requested seasons that are not part of this request we are editing @@ -698,7 +452,7 @@ requestRoutes.delete('/:requestId', async (req, res, next) => { try { const request = await requestRepository.findOneOrFail({ where: { id: Number(req.params.requestId) }, - relations: ['requestedBy', 'modifiedBy'], + relations: { requestedBy: true, modifiedBy: true }, }); if ( @@ -735,7 +489,7 @@ requestRoutes.post<{ try { const request = await requestRepository.findOneOrFail({ where: { id: Number(req.params.requestId) }, - relations: ['requestedBy', 'modifiedBy'], + relations: { requestedBy: true, modifiedBy: true }, }); await request.updateParentStatus(); @@ -763,7 +517,7 @@ requestRoutes.post<{ try { const request = await requestRepository.findOneOrFail({ where: { id: Number(req.params.requestId) }, - relations: ['requestedBy', 'modifiedBy'], + relations: { requestedBy: true, modifiedBy: true }, }); let newStatus: MediaRequestStatus; diff --git a/server/routes/search.ts b/server/routes/search.ts index 3f26a393..1152bce3 100644 --- a/server/routes/search.ts +++ b/server/routes/search.ts @@ -1,10 +1,10 @@ +import TheMovieDb from '@server/api/themoviedb'; +import type { TmdbSearchMultiResponse } from '@server/api/themoviedb/interfaces'; +import Media from '@server/entity/Media'; +import { findSearchProvider } from '@server/lib/search'; +import logger from '@server/logger'; +import { mapSearchResults } from '@server/models/Search'; import { Router } from 'express'; -import TheMovieDb from '../api/themoviedb'; -import { TmdbSearchMultiResponse } from '../api/themoviedb/interfaces'; -import Media from '../entity/Media'; -import { findSearchProvider } from '../lib/search'; -import logger from '../logger'; -import { mapSearchResults } from '../models/Search'; const searchRoutes = Router(); diff --git a/server/routes/service.ts b/server/routes/service.ts index 862ab374..b77d58c9 100644 --- a/server/routes/service.ts +++ b/server/routes/service.ts @@ -1,13 +1,13 @@ -import { Router } from 'express'; -import RadarrAPI from '../api/servarr/radarr'; -import SonarrAPI from '../api/servarr/sonarr'; -import TheMovieDb from '../api/themoviedb'; -import { +import RadarrAPI from '@server/api/servarr/radarr'; +import SonarrAPI from '@server/api/servarr/sonarr'; +import TheMovieDb from '@server/api/themoviedb'; +import type { ServiceCommonServer, ServiceCommonServerWithDetails, -} from '../interfaces/api/serviceInterfaces'; -import { getSettings } from '../lib/settings'; -import logger from '../logger'; +} from '@server/interfaces/api/serviceInterfaces'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { Router } from 'express'; const serviceRoutes = Router(); @@ -191,7 +191,7 @@ serviceRoutes.get<{ tmdbId: string }>( try { const tv = await tmdb.getTvShow({ tvId: Number(req.params.tmdbId), - language: req.locale ?? (req.query.language as string), + language: 'en', }); const response = await sonarr.getSeriesByTitle(tv.name); diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 7ebff760..2d9fc2ff 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -1,35 +1,37 @@ -import { Router } from 'express'; -import rateLimit from 'express-rate-limit'; -import fs from 'fs'; -import { merge, omit, set, sortBy } from 'lodash'; -import { rescheduleJob } from 'node-schedule'; -import path from 'path'; -import semver from 'semver'; -import { getRepository } from 'typeorm'; -import { URL } from 'url'; -import JellyfinAPI from '../../api/jellyfin'; -import PlexAPI from '../../api/plexapi'; -import PlexTvAPI from '../../api/plextv'; -import TautulliAPI from '../../api/tautulli'; -import Media from '../../entity/Media'; -import { MediaRequest } from '../../entity/MediaRequest'; -import { User } from '../../entity/User'; -import { PlexConnection } from '../../interfaces/api/plexInterfaces'; -import { +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 '../../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'; +} 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 { escapeRegExp, merge, omit, set, sortBy } from 'lodash'; +import { rescheduleJob } from 'node-schedule'; +import path from 'path'; +import semver from 'semver'; +import { URL } from 'url'; 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 5a337237..5a38555c 100644 --- a/server/routes/settings/notifications.ts +++ b/server/routes/settings/notifications.ts @@ -1,23 +1,24 @@ +import type { User } from '@server/entity/User'; +import { Notification } from '@server/lib/notifications'; +import type { NotificationAgent } from '@server/lib/notifications/agents/agent'; +import DiscordAgent from '@server/lib/notifications/agents/discord'; +import EmailAgent from '@server/lib/notifications/agents/email'; +import GotifyAgent from '@server/lib/notifications/agents/gotify'; +import LunaSeaAgent from '@server/lib/notifications/agents/lunasea'; +import PushbulletAgent from '@server/lib/notifications/agents/pushbullet'; +import PushoverAgent from '@server/lib/notifications/agents/pushover'; +import SlackAgent from '@server/lib/notifications/agents/slack'; +import TelegramAgent from '@server/lib/notifications/agents/telegram'; +import WebhookAgent from '@server/lib/notifications/agents/webhook'; +import WebPushAgent from '@server/lib/notifications/agents/webpush'; +import { getSettings } from '@server/lib/settings'; import { Router } from 'express'; -import { User } from '../../entity/User'; -import { Notification } from '../../lib/notifications'; -import { NotificationAgent } from '../../lib/notifications/agents/agent'; -import DiscordAgent from '../../lib/notifications/agents/discord'; -import EmailAgent from '../../lib/notifications/agents/email'; -import GotifyAgent from '../../lib/notifications/agents/gotify'; -import LunaSeaAgent from '../../lib/notifications/agents/lunasea'; -import PushbulletAgent from '../../lib/notifications/agents/pushbullet'; -import PushoverAgent from '../../lib/notifications/agents/pushover'; -import SlackAgent from '../../lib/notifications/agents/slack'; -import TelegramAgent from '../../lib/notifications/agents/telegram'; -import WebhookAgent from '../../lib/notifications/agents/webhook'; -import WebPushAgent from '../../lib/notifications/agents/webpush'; -import { getSettings } from '../../lib/settings'; const notificationRoutes = Router(); const sendTestNotification = async (agent: NotificationAgent, user: User) => await agent.send(Notification.TEST_NOTIFICATION, { + notifySystem: true, notifyAdmin: false, notifyUser: user, subject: 'Test Notification', @@ -247,7 +248,7 @@ notificationRoutes.post('/webpush/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, - message: 'User information missing from request', + message: 'User information is missing from the request.', }); } @@ -363,7 +364,7 @@ notificationRoutes.post('/lunasea/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, - message: 'User information missing from request', + message: 'User information is missing from the request.', }); } @@ -384,34 +385,26 @@ notificationRoutes.get('/gotify', (_req, res) => { res.status(200).json(settings.notifications.agents.gotify); }); -notificationRoutes.post('/gotify', (req, rest) => { +notificationRoutes.post('/gotify', (req, res) => { const settings = getSettings(); settings.notifications.agents.gotify = req.body; settings.save(); - rest.status(200).json(settings.notifications.agents.gotify); + res.status(200).json(settings.notifications.agents.gotify); }); -notificationRoutes.post('/gotify/test', async (req, rest, next) => { +notificationRoutes.post('/gotify/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, - message: 'User information is missing from request', + message: 'User information is missing from the request.', }); } const gotifyAgent = new GotifyAgent(req.body); - if ( - await gotifyAgent.send(Notification.TEST_NOTIFICATION, { - notifyAdmin: false, - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }) - ) { - return rest.status(204).send(); + if (await sendTestNotification(gotifyAgent, req.user)) { + return res.status(204).send(); } else { return next({ status: 500, diff --git a/server/routes/settings/radarr.ts b/server/routes/settings/radarr.ts index a33bfcdb..c2b0a6f5 100644 --- a/server/routes/settings/radarr.ts +++ b/server/routes/settings/radarr.ts @@ -1,7 +1,8 @@ +import RadarrAPI from '@server/api/servarr/radarr'; +import type { RadarrSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; import { Router } from 'express'; -import RadarrAPI from '../../api/servarr/radarr'; -import { getSettings, RadarrSettings } from '../../lib/settings'; -import logger from '../../logger'; const radarrRoutes = Router(); diff --git a/server/routes/settings/sonarr.ts b/server/routes/settings/sonarr.ts index da5a5bb3..358d0700 100644 --- a/server/routes/settings/sonarr.ts +++ b/server/routes/settings/sonarr.ts @@ -1,7 +1,8 @@ +import SonarrAPI from '@server/api/servarr/sonarr'; +import type { SonarrSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; import { Router } from 'express'; -import SonarrAPI from '../../api/servarr/sonarr'; -import { getSettings, SonarrSettings } from '../../lib/settings'; -import logger from '../../logger'; const sonarrRoutes = Router(); diff --git a/server/routes/tv.ts b/server/routes/tv.ts index 201e7afe..d45e4062 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -1,11 +1,11 @@ +import RottenTomatoes from '@server/api/rottentomatoes'; +import TheMovieDb from '@server/api/themoviedb'; +import { MediaType } from '@server/constants/media'; +import Media from '@server/entity/Media'; +import logger from '@server/logger'; +import { mapTvResult } from '@server/models/Search'; +import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv'; import { Router } from 'express'; -import RottenTomatoes from '../api/rottentomatoes'; -import TheMovieDb from '../api/themoviedb'; -import { MediaType } from '../constants/media'; -import Media from '../entity/Media'; -import logger from '../logger'; -import { mapTvResult } from '../models/Search'; -import { mapSeasonWithEpisodes, mapTvDetails } from '../models/Tv'; const tvRoutes = Router(); diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 5811fc05..258a3eae 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 a05311a2..9b9a11ec 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -1,16 +1,16 @@ -import { Router } from 'express'; -import { getRepository } from 'typeorm'; -import { canMakePermissionsChange } from '.'; -import { User } from '../../entity/User'; -import { UserSettings } from '../../entity/UserSettings'; -import { +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import { UserSettings } from '@server/entity/UserSettings'; +import type { UserSettingsGeneralResponse, UserSettingsNotificationsResponse, -} from '../../interfaces/api/userSettingsInterfaces'; -import { Permission } from '../../lib/permissions'; -import { getSettings } from '../../lib/settings'; -import logger from '../../logger'; -import { isAuthenticated } from '../../middleware/auth'; +} from '@server/interfaces/api/userSettingsInterfaces'; +import { Permission } from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { isAuthenticated } from '@server/middleware/auth'; +import { Router } from 'express'; +import { canMakePermissionsChange } from '.'; const isOwnProfileOrAdmin = (): Middleware => { const authMiddleware: Middleware = (req, res, next) => { @@ -64,6 +64,8 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>( globalMovieQuotaLimit: defaultQuotas.movie.quotaLimit, globalTvQuotaDays: defaultQuotas.tv.quotaDays, globalTvQuotaLimit: defaultQuotas.tv.quotaLimit, + watchlistSyncMovies: user.settings?.watchlistSyncMovies, + watchlistSyncTv: user.settings?.watchlistSyncTv, }); } catch (e) { next({ status: 500, message: e.message }); @@ -115,12 +117,16 @@ userSettingsRoutes.post< locale: req.body.locale, region: req.body.region, originalLanguage: req.body.originalLanguage, + watchlistSyncMovies: req.body.watchlistSyncMovies, + watchlistSyncTv: req.body.watchlistSyncTv, }); } else { user.settings.discordId = req.body.discordId; user.settings.locale = req.body.locale; user.settings.region = req.body.region; user.settings.originalLanguage = req.body.originalLanguage; + user.settings.watchlistSyncMovies = req.body.watchlistSyncMovies; + user.settings.watchlistSyncTv = req.body.watchlistSyncTv; user.email = req.body.email ?? user.email; } @@ -132,6 +138,8 @@ userSettingsRoutes.post< locale: user.settings.locale, region: user.settings.region, originalLanguage: user.settings.originalLanguage, + watchlistSyncMovies: user.settings.watchlistSyncMovies, + watchlistSyncTv: user.settings.watchlistSyncTv, email: user.email, }); } catch (e) { diff --git a/server/scripts/prepareTestDb.ts b/server/scripts/prepareTestDb.ts new file mode 100644 index 00000000..7caede41 --- /dev/null +++ b/server/scripts/prepareTestDb.ts @@ -0,0 +1,72 @@ +import { UserType } from '@server/constants/user'; +import dataSource, { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import { copyFileSync } from 'fs'; +import gravatarUrl from 'gravatar-url'; +import path from 'path'; + +const prepareDb = async () => { + // Copy over test settings.json + copyFileSync( + path.join(__dirname, '../../cypress/config/settings.cypress.json'), + path.join(__dirname, '../../config/settings.json') + ); + + // Connect to DB and seed test data + const dbConnection = await dataSource.initialize(); + + if (process.env.PRESERVE_DB !== 'true') { + await dbConnection.dropDatabase(); + } + + // Run migrations in production + if (process.env.WITH_MIGRATIONS === 'true') { + await dbConnection.runMigrations(); + } else { + await dbConnection.synchronize(); + } + + const userRepository = getRepository(User); + + const admin = await userRepository.findOne({ + select: { id: true, plexId: true }, + where: { id: 1 }, + }); + + // Create the admin user + const user = + (await userRepository.findOne({ + where: { email: 'admin@seerr.dev' }, + })) ?? new User(); + user.plexId = admin?.plexId ?? 1; + user.plexToken = '1234'; + user.plexUsername = 'admin'; + user.username = 'admin'; + user.email = 'admin@seerr.dev'; + user.userType = UserType.PLEX; + await user.setPassword('test1234'); + user.permissions = 2; + user.avatar = gravatarUrl('admin@seerr.dev', { default: 'mm', size: 200 }); + await userRepository.save(user); + + // Create the other user + const otherUser = + (await userRepository.findOne({ + where: { email: 'friend@seerr.dev' }, + })) ?? new User(); + otherUser.plexId = admin?.plexId ?? 1; + otherUser.plexToken = '1234'; + otherUser.plexUsername = 'friend'; + otherUser.username = 'friend'; + otherUser.email = 'friend@seerr.dev'; + otherUser.userType = UserType.PLEX; + await otherUser.setPassword('test1234'); + otherUser.permissions = 32; + otherUser.avatar = gravatarUrl('friend@seerr.dev', { + default: 'mm', + size: 200, + }); + await userRepository.save(otherUser); +}; + +prepareDb(); diff --git a/server/subscriber/IssueCommentSubscriber.ts b/server/subscriber/IssueCommentSubscriber.ts index 1b1b7b55..cb95ba00 100644 --- a/server/subscriber/IssueCommentSubscriber.ts +++ b/server/subscriber/IssueCommentSubscriber.ts @@ -1,18 +1,15 @@ +import TheMovieDb from '@server/api/themoviedb'; +import { IssueType, IssueTypeName } from '@server/constants/issue'; +import { MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import IssueComment from '@server/entity/IssueComment'; +import Media from '@server/entity/Media'; +import notificationManager, { Notification } from '@server/lib/notifications'; +import { Permission } from '@server/lib/permissions'; +import logger from '@server/logger'; import { sortBy } from 'lodash'; -import { - EntitySubscriberInterface, - EventSubscriber, - getRepository, - InsertEvent, -} from 'typeorm'; -import TheMovieDb from '../api/themoviedb'; -import { IssueType, IssueTypeName } from '../constants/issue'; -import { MediaType } from '../constants/media'; -import IssueComment from '../entity/IssueComment'; -import Media from '../entity/Media'; -import notificationManager, { Notification } from '../lib/notifications'; -import { Permission } from '../lib/permissions'; -import logger from '../logger'; +import type { EntitySubscriberInterface, InsertEvent } from 'typeorm'; +import { EventSubscriber } from 'typeorm'; @EventSubscriber() export class IssueCommentSubscriber @@ -31,7 +28,7 @@ export class IssueCommentSubscriber const issue = ( await getRepository(IssueComment).findOneOrFail({ where: { id: entity.id }, - relations: ['issue'], + relations: { issue: true }, }) ).issue; @@ -72,6 +69,7 @@ export class IssueCommentSubscriber media, image, notifyAdmin: true, + notifySystem: true, notifyUser: !issue.createdBy.hasPermission(Permission.MANAGE_ISSUES) && issue.createdBy.id !== entity.user.id diff --git a/server/subscriber/IssueSubscriber.ts b/server/subscriber/IssueSubscriber.ts index b593095c..eb402041 100644 --- a/server/subscriber/IssueSubscriber.ts +++ b/server/subscriber/IssueSubscriber.ts @@ -1,17 +1,17 @@ +import TheMovieDb from '@server/api/themoviedb'; +import { IssueStatus, IssueType, IssueTypeName } from '@server/constants/issue'; +import { MediaType } from '@server/constants/media'; +import Issue from '@server/entity/Issue'; +import notificationManager, { Notification } from '@server/lib/notifications'; +import { Permission } from '@server/lib/permissions'; +import logger from '@server/logger'; import { sortBy } from 'lodash'; -import { +import type { EntitySubscriberInterface, - EventSubscriber, InsertEvent, UpdateEvent, } from 'typeorm'; -import TheMovieDb from '../api/themoviedb'; -import { IssueStatus, IssueType, IssueTypeName } from '../constants/issue'; -import { MediaType } from '../constants/media'; -import Issue from '../entity/Issue'; -import notificationManager, { Notification } from '../lib/notifications'; -import { Permission } from '../lib/permissions'; -import logger from '../logger'; +import { EventSubscriber } from 'typeorm'; @EventSubscriber() export class IssueSubscriber implements EntitySubscriberInterface { @@ -84,6 +84,7 @@ export class IssueSubscriber implements EntitySubscriberInterface { image, extra, notifyAdmin: true, + notifySystem: true, notifyUser: !entity.createdBy.hasPermission(Permission.MANAGE_ISSUES) && (type === Notification.ISSUE_RESOLVED || diff --git a/server/subscriber/MediaSubscriber.ts b/server/subscriber/MediaSubscriber.ts index 01752b0d..eecfe6f3 100644 --- a/server/subscriber/MediaSubscriber.ts +++ b/server/subscriber/MediaSubscriber.ts @@ -1,18 +1,18 @@ -import { truncate } from 'lodash'; +import TheMovieDb from '@server/api/themoviedb'; import { - EntitySubscriberInterface, - EventSubscriber, - getRepository, - Not, - UpdateEvent, -} from 'typeorm'; -import TheMovieDb from '../api/themoviedb'; -import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media'; -import Media from '../entity/Media'; -import { MediaRequest } from '../entity/MediaRequest'; -import Season from '../entity/Season'; -import notificationManager, { Notification } from '../lib/notifications'; -import logger from '../logger'; + MediaRequestStatus, + MediaStatus, + MediaType, +} from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { MediaRequest } from '@server/entity/MediaRequest'; +import Season from '@server/entity/Season'; +import notificationManager, { Notification } from '@server/lib/notifications'; +import logger from '@server/logger'; +import { truncate } from 'lodash'; +import type { EntitySubscriberInterface, UpdateEvent } from 'typeorm'; +import { EventSubscriber, In, Not } from 'typeorm'; @EventSubscriber() export class MediaSubscriber implements EntitySubscriberInterface { @@ -29,7 +29,9 @@ export class MediaSubscriber implements EntitySubscriberInterface { const requestRepository = getRepository(MediaRequest); const relatedRequests = await requestRepository.find({ where: { - media: entity, + media: { + id: entity.id, + }, is4k, status: Not(MediaRequestStatus.DECLINED), }, @@ -47,6 +49,7 @@ export class MediaSubscriber implements EntitySubscriberInterface { { event: `${is4k ? '4K ' : ''}Movie Request Now Available`, notifyAdmin: false, + notifySystem: true, notifyUser: request.requestedBy, subject: `${movie.title}${ movie.release_date @@ -89,7 +92,7 @@ export class MediaSubscriber implements EntitySubscriberInterface { ) .map((season) => season.seasonNumber); const oldSeasonIds = dbEntity.seasons.map((season) => season.id); - const oldSeasons = await seasonRepository.findByIds(oldSeasonIds); + const oldSeasons = await seasonRepository.findBy({ id: In(oldSeasonIds) }); const oldAvailableSeasons = oldSeasons .filter( (season) => @@ -109,7 +112,9 @@ export class MediaSubscriber implements EntitySubscriberInterface { for (const changedSeasonNumber of changedSeasons) { const requests = await requestRepository.find({ where: { - media: entity, + media: { + id: entity.id, + }, is4k, status: Not(MediaRequestStatus.DECLINED), }, @@ -143,6 +148,7 @@ export class MediaSubscriber implements EntitySubscriberInterface { omission: '…', }), notifyAdmin: false, + notifySystem: true, notifyUser: request.requestedBy, image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`, media: entity, @@ -172,7 +178,7 @@ export class MediaSubscriber implements EntitySubscriberInterface { const requestRepository = getRepository(MediaRequest); const requests = await requestRepository.find({ - where: { media: event.id }, + where: { media: { id: event.id } }, }); for (const request of requests) { diff --git a/server/tsconfig.json b/server/tsconfig.json index d245100d..ec4b9004 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -4,7 +4,11 @@ "target": "ES2020", "module": "commonjs", "outDir": "../dist", - "noEmit": false + "noEmit": false, + "baseUrl": ".", + "paths": { + "@server/*": ["*"] + } }, "include": ["**/*.ts"] } diff --git a/server/types/express.d.ts b/server/types/express.d.ts index ee7fd972..7b82477a 100644 --- a/server/types/express.d.ts +++ b/server/types/express.d.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ +import type { User } from '@server/entity/User'; import type { NextFunction, Request, Response } from 'express'; -import type { User } from '../entity/User'; declare global { namespace Express { diff --git a/server/utils/appVersion.ts b/server/utils/appVersion.ts index 923d4708..d01a08a9 100644 --- a/server/utils/appVersion.ts +++ b/server/utils/appVersion.ts @@ -1,6 +1,6 @@ +import logger from '@server/logger'; import { existsSync } from 'fs'; import path from 'path'; -import logger from '../logger'; const COMMIT_TAG_PATH = path.join(__dirname, '../../committag.json'); let commitTag = 'local'; diff --git a/server/utils/dateHelpers.ts b/server/utils/dateHelpers.ts new file mode 100644 index 00000000..4684d783 --- /dev/null +++ b/server/utils/dateHelpers.ts @@ -0,0 +1,4 @@ +import { addYears } from 'date-fns'; +import { Between } from 'typeorm'; + +export const AfterDate = (date: Date) => Between(date, addYears(date, 100)); diff --git a/server/utils/restartFlag.ts b/server/utils/restartFlag.ts new file mode 100644 index 00000000..387ec5ce --- /dev/null +++ b/server/utils/restartFlag.ts @@ -0,0 +1,23 @@ +import type { MainSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; + +class RestartFlag { + private settings: MainSettings; + + public initializeSettings(settings: MainSettings): void { + this.settings = { ...settings }; + } + + public isSet(): boolean { + const settings = getSettings().main; + + return ( + this.settings.csrfProtection !== settings.csrfProtection || + this.settings.trustProxy !== settings.trustProxy + ); + } +} + +const restartFlag = new RestartFlag(); + +export default restartFlag; diff --git a/server/utils/typeHelpers.ts b/server/utils/typeHelpers.ts index 04070244..507ece8c 100644 --- a/server/utils/typeHelpers.ts +++ b/server/utils/typeHelpers.ts @@ -5,7 +5,7 @@ import type { TmdbPersonResult, TmdbTvDetails, TmdbTvResult, -} from '../api/themoviedb/interfaces'; +} from '@server/api/themoviedb/interfaces'; export const isMovie = ( movie: TmdbMovieResult | TmdbTvResult | TmdbPersonResult diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 0a7099cc..3b693643 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -8,10 +8,15 @@ description: > base: core18 confinement: strict +architectures: + - build-on: amd64 + - build-on: arm64 + - build-on: armhf + parts: overseerr: plugin: nodejs - nodejs-version: '16.14.0' + nodejs-version: '16.17.0' nodejs-package-manager: 'yarn' nodejs-yarn-version: v1.22.17 build-packages: @@ -31,13 +36,16 @@ parts: override-pull: | snapcraftctl pull # Get information to determine snap grade and version + git config --global --add safe.directory /data/parts/overseerr/src + #setup yarn.rc + echo "--install.frozen-lockfile\n--install.network-timeout 1000000" > .yarnrc BRANCH=$(git rev-parse --abbrev-ref HEAD) COMMIT=$(git rev-parse HEAD) COMMIT_SHORT=$(git rev-parse --short HEAD) VERSION='v'$(cat package.json | grep 'version' | head -1 | sed 's/.*"\(.*\)"\,/\1/') if [ "$VERSION" = "v0.1.0" ]; then SNAP_VERSION=$COMMIT_SHORT - GRADE=devel + GRADE=stable else SNAP_VERSION=$VERSION GRADE=stable @@ -57,6 +65,7 @@ parts: snapcraftctl set-grade "$GRADE" build-environment: - PATH: '$SNAPCRAFT_PART_BUILD/node_modules/.bin:$SNAPCRAFT_PART_BUILD/../npm/bin:$PATH' + - CYPRESS_INSTALL_BINARY: '0' override-build: | set -e # Set COMMIT_TAG before the build begins @@ -77,7 +86,7 @@ parts: prime: [.next, ./*] apps: - deamon: + daemon: command: /bin/sh -c "cd $SNAP && node dist/index.js" daemon: simple restart-condition: on-failure diff --git a/src/assets/infinity.svg b/src/assets/infinity.svg new file mode 100644 index 00000000..054149f8 --- /dev/null +++ b/src/assets/infinity.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/AirDateBadge/index.tsx b/src/components/AirDateBadge/index.tsx new file mode 100644 index 00000000..fb9268f6 --- /dev/null +++ b/src/components/AirDateBadge/index.tsx @@ -0,0 +1,62 @@ +import Badge from '@app/components/Common/Badge'; +import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; + +const messages = defineMessages({ + airedrelative: 'Aired {relativeTime}', + airsrelative: 'Airing {relativeTime}', +}); + +type AirDateBadgeProps = { + airDate: string; +}; + +const AirDateBadge = ({ airDate }: AirDateBadgeProps) => { + const WEEK = 1000 * 60 * 60 * 24 * 8; + const intl = useIntl(); + const dAirDate = new Date(airDate); + const nowDate = new Date(); + const alreadyAired = dAirDate.getTime() < nowDate.getTime(); + + const compareWeek = new Date( + alreadyAired ? Date.now() - WEEK : Date.now() + WEEK + ); + + let showRelative = false; + + if ( + (alreadyAired && dAirDate.getTime() > compareWeek.getTime()) || + (!alreadyAired && dAirDate.getTime() < compareWeek.getTime()) + ) { + showRelative = true; + } + + return ( +
+ + {intl.formatDate(dAirDate, { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + + {showRelative && ( + + {intl.formatMessage( + alreadyAired ? messages.airedrelative : messages.airsrelative, + { + relativeTime: ( + + ), + } + )} + + )} +
+ ); +}; + +export default AirDateBadge; diff --git a/src/components/AppDataWarning/index.tsx b/src/components/AppDataWarning/index.tsx index fce97bd5..21c3dbae 100644 --- a/src/components/AppDataWarning/index.tsx +++ b/src/components/AppDataWarning/index.tsx @@ -1,14 +1,13 @@ -import React from 'react'; +import Alert from '@app/components/Common/Alert'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import Alert from '../Common/Alert'; const messages = defineMessages({ dockerVolumeMissingDescription: 'The {appDataPath} volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.', }); -const AppDataWarning: React.FC = () => { +const AppDataWarning = () => { const intl = useIntl(); const { data, error } = useSWR<{ appData: boolean; appDataPath: string }>( '/api/v1/status/appdata' @@ -27,9 +26,9 @@ const AppDataWarning: React.FC = () => { {!data.appData && ( {msg}; - }, + code: (msg: React.ReactNode) => ( + {msg} + ), appDataPath: data.appDataPath, })} /> diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index 839f019a..52bd8a26 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -1,24 +1,24 @@ +import ButtonWithDropdown from '@app/components/Common/ButtonWithDropdown'; +import CachedImage from '@app/components/Common/CachedImage'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import RequestModal from '@app/components/RequestModal'; +import Slider from '@app/components/Slider'; +import StatusBadge from '@app/components/StatusBadge'; +import TitleCard from '@app/components/TitleCard'; +import useSettings from '@app/hooks/useSettings'; +import { Permission, useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; +import Error from '@app/pages/_error'; import { DownloadIcon } from '@heroicons/react/outline'; +import { MediaStatus } from '@server/constants/media'; +import type { Collection } from '@server/models/Collection'; import { uniq } from 'lodash'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React, { useState } from 'react'; +import { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import { MediaStatus } from '../../../server/constants/media'; -import type { Collection } from '../../../server/models/Collection'; -import useSettings from '../../hooks/useSettings'; -import { Permission, useUser } from '../../hooks/useUser'; -import globalMessages from '../../i18n/globalMessages'; -import Error from '../../pages/_error'; -import ButtonWithDropdown from '../Common/ButtonWithDropdown'; -import CachedImage from '../Common/CachedImage'; -import LoadingSpinner from '../Common/LoadingSpinner'; -import PageTitle from '../Common/PageTitle'; -import RequestModal from '../RequestModal'; -import Slider from '../Slider'; -import StatusBadge from '../StatusBadge'; -import TitleCard from '../TitleCard'; const messages = defineMessages({ overview: 'Overview', @@ -31,9 +31,7 @@ interface CollectionDetailsProps { collection?: Collection; } -const CollectionDetails: React.FC = ({ - collection, -}) => { +const CollectionDetails = ({ collection }: CollectionDetailsProps) => { const intl = useIntl(); const router = useRouter(); const settings = useSettings(); diff --git a/src/components/Common/Accordion/index.tsx b/src/components/Common/Accordion/index.tsx index 67e883fe..49187bd0 100644 --- a/src/components/Common/Accordion/index.tsx +++ b/src/components/Common/Accordion/index.tsx @@ -1,9 +1,9 @@ -import * as React from 'react'; +import type * as React from 'react'; import { useState } from 'react'; import AnimateHeight from 'react-animate-height'; export interface AccordionProps { - children: (args: AccordionChildProps) => React.ReactElement | null; + children: (args: AccordionChildProps) => JSX.Element; /** If true, only one accordion item can be open at any time */ single?: boolean; /** If true, at least one accordion item will always be open */ @@ -13,22 +13,27 @@ export interface AccordionProps { export interface AccordionChildProps { openIndexes: number[]; handleClick(index: number): void; - AccordionContent: any; + AccordionContent: typeof AccordionContent; } -export const AccordionContent: React.FC<{ isOpen: boolean }> = ({ +type AccordionContentProps = { + isOpen: boolean; + children: React.ReactNode; +}; + +export const AccordionContent = ({ isOpen, children, -}) => { +}: AccordionContentProps) => { return {children}; }; -const Accordion: React.FC = ({ +const Accordion = ({ single, atLeastOne, initialOpenIndexes, children, -}) => { +}: AccordionProps) => { const initialState = initialOpenIndexes || (atLeastOne && [0]) || []; const [openIndexes, setOpenIndexes] = useState(initialState); diff --git a/src/components/Common/Alert/index.tsx b/src/components/Common/Alert/index.tsx index e9789c70..8ffb4a25 100644 --- a/src/components/Common/Alert/index.tsx +++ b/src/components/Common/Alert/index.tsx @@ -3,16 +3,17 @@ import { InformationCircleIcon, XCircleIcon, } from '@heroicons/react/solid'; -import React from 'react'; interface AlertProps { title?: React.ReactNode; type?: 'warning' | 'info' | 'error'; + children?: React.ReactNode; } -const Alert: React.FC = ({ title, children, type }) => { +const Alert = ({ title, children, type }: AlertProps) => { let design = { - bgColor: 'bg-yellow-600', + bgColor: + 'border border-yellow-500 backdrop-blur bg-yellow-400 bg-opacity-20', titleColor: 'text-yellow-100', textColor: 'text-yellow-300', svg: , @@ -21,9 +22,10 @@ const Alert: React.FC = ({ title, children, type }) => { switch (type) { case 'info': design = { - bgColor: 'bg-indigo-600', - titleColor: 'text-indigo-100', - textColor: 'text-indigo-300', + bgColor: + 'border border-indigo-500 backdrop-blur bg-indigo-400 bg-opacity-20', + titleColor: 'text-gray-100', + textColor: 'text-gray-300', svg: , }; break; diff --git a/src/components/Common/Badge/index.tsx b/src/components/Common/Badge/index.tsx index 33e55ab7..47ce6586 100644 --- a/src/components/Common/Badge/index.tsx +++ b/src/components/Common/Badge/index.tsx @@ -2,17 +2,23 @@ import Link from 'next/link'; import React from 'react'; interface BadgeProps { - badgeType?: 'default' | 'primary' | 'danger' | 'warning' | 'success'; + badgeType?: + | 'default' + | 'primary' + | 'danger' + | 'warning' + | 'success' + | 'dark' + | 'light'; className?: string; href?: string; + children: React.ReactNode; } -const Badge: React.FC = ({ - badgeType = 'default', - className, - href, - children, -}) => { +const Badge = ( + { badgeType = 'default', className, href, children }: BadgeProps, + ref?: React.Ref +) => { const badgeStyle = [ 'px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap', ]; @@ -25,27 +31,47 @@ const Badge: React.FC = ({ switch (badgeType) { case 'danger': - badgeStyle.push('bg-red-600 !text-red-100'); + badgeStyle.push( + 'bg-red-600 bg-opacity-80 border-red-500 border !text-red-100' + ); if (href) { - badgeStyle.push('hover:bg-red-500'); + badgeStyle.push('hover:bg-red-500 bg-opacity-100'); } break; case 'warning': - badgeStyle.push('bg-yellow-500 !text-yellow-100'); + badgeStyle.push( + 'bg-yellow-500 bg-opacity-80 border-yellow-500 border !text-yellow-100' + ); if (href) { - badgeStyle.push('hover:bg-yellow-400'); + badgeStyle.push('hover:bg-yellow-500 hover:bg-opacity-100'); } break; case 'success': - badgeStyle.push('bg-green-500 !text-green-100'); + badgeStyle.push( + 'bg-green-500 bg-opacity-80 border border-green-500 !text-green-100' + ); if (href) { - badgeStyle.push('hover:bg-green-400'); + badgeStyle.push('hover:bg-green-500 hover:bg-opacity-100'); + } + break; + case 'dark': + badgeStyle.push('bg-gray-900 !text-gray-400'); + if (href) { + badgeStyle.push('hover:bg-gray-800'); + } + break; + case 'light': + badgeStyle.push('bg-gray-700 !text-gray-300'); + if (href) { + badgeStyle.push('hover:bg-gray-600'); } break; default: - badgeStyle.push('bg-indigo-500 !text-indigo-100'); + badgeStyle.push( + 'bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100' + ); if (href) { - badgeStyle.push('hover:bg-indigo-400'); + badgeStyle.push('hover:bg-indigo-500 bg-opacity-100'); } } @@ -60,6 +86,7 @@ const Badge: React.FC = ({ target="_blank" rel="noopener noreferrer" className={badgeStyle.join(' ')} + ref={ref as React.Ref} > {children} @@ -67,12 +94,24 @@ const Badge: React.FC = ({ } else if (href) { return ( - {children} + } + > + {children} + ); } else { - return {children}; + return ( + } + > + {children} + + ); } }; -export default Badge; +export default React.forwardRef(Badge) as typeof Badge; diff --git a/src/components/Common/Button/index.tsx b/src/components/Common/Button/index.tsx index f1083e5b..7dc4e637 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 6edb4a11..be6815b9 100644 --- a/src/components/Common/ButtonWithDropdown/index.tsx +++ b/src/components/Common/ButtonWithDropdown/index.tsx @@ -1,34 +1,29 @@ +import useClickOutside from '@app/hooks/useClickOutside'; +import { withProperties } from '@app/utils/typeHelpers'; +import { Transition } from '@headlessui/react'; import { ChevronDownIcon } from '@heroicons/react/solid'; -import React, { - AnchorHTMLAttributes, - ButtonHTMLAttributes, - ReactNode, - useRef, - useState, -} from 'react'; -import useClickOutside from '../../../hooks/useClickOutside'; -import { withProperties } from '../../../utils/typeHelpers'; -import Transition from '../../Transition'; +import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react'; +import { Fragment, useRef, useState } from 'react'; interface DropdownItemProps extends AnchorHTMLAttributes { buttonType?: 'primary' | 'ghost'; } -const DropdownItem: React.FC = ({ +const DropdownItem = ({ children, buttonType = 'primary', ...props -}) => { +}: DropdownItemProps) => { let styleClass = 'button-md text-white'; switch (buttonType) { case 'ghost': styleClass += - ' bg-gray-700 hover:bg-gray-600 focus:border-gray-500 focus:text-white'; + ' bg-transparent rounded hover:bg-gradient-to-br from-indigo-600 to-purple-600 text-white focus:border-gray-500 focus:text-white'; break; default: styleClass += - ' bg-indigo-600 hover:bg-indigo-500 focus:border-indigo-700 focus:text-white'; + ' bg-indigo-600 rounded hover:bg-indigo-500 focus:border-indigo-700 focus:text-white'; } return ( = ({ interface ButtonWithDropdownProps extends ButtonHTMLAttributes { - text: ReactNode; - dropdownIcon?: ReactNode; + text: React.ReactNode; + dropdownIcon?: React.ReactNode; buttonType?: 'primary' | 'ghost'; } -const ButtonWithDropdown: React.FC = ({ +const ButtonWithDropdown = ({ text, children, dropdownIcon, className, buttonType = 'primary', ...props -}) => { +}: ButtonWithDropdownProps) => { const [isOpen, setIsOpen] = useState(false); const buttonRef = useRef(null); useClickOutside(buttonRef, () => setIsOpen(false)); @@ -70,14 +65,15 @@ const ButtonWithDropdown: React.FC = ({ styleClasses.mainButtonClasses += ' bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'; styleClasses.dropdownSideButtonClasses = styleClasses.mainButtonClasses; - styleClasses.dropdownClasses += ' bg-gray-700'; + styleClasses.dropdownClasses += + ' bg-gray-800 border border-gray-700 bg-opacity-80 p-1 backdrop-blur'; break; default: styleClasses.mainButtonClasses += - ' bg-indigo-600 border-indigo-600 hover:bg-indigo-500 hover:border-indigo-500 active:bg-indigo-700 active:border-indigo-700 focus:ring-blue'; + ' bg-indigo-600 border-indigo-500 bg-opacity-80 hover:bg-opacity-100 hover:border-indigo-500 active:bg-indigo-700 active:border-indigo-700 focus:ring-blue'; styleClasses.dropdownSideButtonClasses += - ' bg-indigo-700 border-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue'; - styleClasses.dropdownClasses += ' bg-indigo-600'; + ' bg-indigo-600 bg-opacity-80 border-indigo-500 hover:bg-opacity-100 active:bg-opacity-100 focus:ring-blue'; + styleClasses.dropdownClasses += ' bg-indigo-600 p-1'; } return ( @@ -103,6 +99,7 @@ const ButtonWithDropdown: React.FC = ({ {dropdownIcon ? dropdownIcon : } = (props) => { +const CachedImage = (props: ImageProps) => { const { currentSettings } = useSettings(); return ; diff --git a/src/components/Common/ConfirmButton/index.tsx b/src/components/Common/ConfirmButton/index.tsx index df3c6572..1f5756cb 100644 --- a/src/components/Common/ConfirmButton/index.tsx +++ b/src/components/Common/ConfirmButton/index.tsx @@ -1,19 +1,20 @@ -import React, { useRef, useState } from 'react'; -import useClickOutside from '../../../hooks/useClickOutside'; -import Button from '../Button'; +import Button from '@app/components/Common/Button'; +import useClickOutside from '@app/hooks/useClickOutside'; +import { useRef, useState } from 'react'; interface ConfirmButtonProps { onClick: () => void; confirmText: React.ReactNode; className?: string; + children: React.ReactNode; } -const ConfirmButton: React.FC = ({ +const ConfirmButton = ({ onClick, children, confirmText, className, -}) => { +}: ConfirmButtonProps) => { const ref = useRef(null); useClickOutside(ref, () => setIsClicked(false)); const [isClicked, setIsClicked] = useState(false); diff --git a/src/components/Common/Header/index.tsx b/src/components/Common/Header/index.tsx index b7c88ddd..1653a457 100644 --- a/src/components/Common/Header/index.tsx +++ b/src/components/Common/Header/index.tsx @@ -1,22 +1,18 @@ -import React from 'react'; - interface HeaderProps { extraMargin?: number; subtext?: React.ReactNode; + children: React.ReactNode; } -const Header: React.FC = ({ - children, - extraMargin = 0, - subtext, -}) => { +const Header = ({ children, extraMargin = 0, subtext }: HeaderProps) => { return (

-

- - {children} - +

+ {children}

{subtext &&
{subtext}
}
diff --git a/src/components/Common/ImageFader/index.tsx b/src/components/Common/ImageFader/index.tsx index 5f68376c..a5717241 100644 --- a/src/components/Common/ImageFader/index.tsx +++ b/src/components/Common/ImageFader/index.tsx @@ -1,10 +1,6 @@ -import React, { - ForwardRefRenderFunction, - HTMLAttributes, - useEffect, - useState, -} from 'react'; -import CachedImage from '../CachedImage'; +import CachedImage from '@app/components/Common/CachedImage'; +import type { ForwardRefRenderFunction, HTMLAttributes } from 'react'; +import React, { useEffect, useState } from 'react'; interface ImageFaderProps extends HTMLAttributes { backgroundImages: string[]; diff --git a/src/components/Common/List/index.tsx b/src/components/Common/List/index.tsx index a4b91723..32057ed1 100644 --- a/src/components/Common/List/index.tsx +++ b/src/components/Common/List/index.tsx @@ -1,12 +1,12 @@ -import React from 'react'; -import { withProperties } from '../../../utils/typeHelpers'; +import { withProperties } from '@app/utils/typeHelpers'; interface ListItemProps { title: string; className?: string; + children: React.ReactNode; } -const ListItem: React.FC = ({ title, className, children }) => { +const ListItem = ({ title, className, children }: ListItemProps) => { return (
@@ -22,9 +22,10 @@ const ListItem: React.FC = ({ title, className, children }) => { interface ListProps { title: string; subTitle?: string; + children: React.ReactNode; } -const List: React.FC = ({ title, subTitle, children }) => { +const List = ({ title, subTitle, children }: ListProps) => { return ( <>
diff --git a/src/components/Common/ListView/index.tsx b/src/components/Common/ListView/index.tsx index 0c2a0e4e..6f09f768 100644 --- a/src/components/Common/ListView/index.tsx +++ b/src/components/Common/ListView/index.tsx @@ -1,30 +1,33 @@ -import React from 'react'; -import { useIntl } from 'react-intl'; -import { +import PersonCard from '@app/components/PersonCard'; +import TitleCard from '@app/components/TitleCard'; +import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard'; +import useVerticalScroll from '@app/hooks/useVerticalScroll'; +import globalMessages from '@app/i18n/globalMessages'; +import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces'; +import type { MovieResult, PersonResult, TvResult, -} from '../../../../server/models/Search'; -import useVerticalScroll from '../../../hooks/useVerticalScroll'; -import globalMessages from '../../../i18n/globalMessages'; -import PersonCard from '../../PersonCard'; -import TitleCard from '../../TitleCard'; +} from '@server/models/Search'; +import { useIntl } from 'react-intl'; -interface ListViewProps { +type ListViewProps = { items?: (TvResult | MovieResult | PersonResult)[]; + plexItems?: WatchlistItem[]; isEmpty?: boolean; isLoading?: boolean; isReachingEnd?: boolean; onScrollBottom: () => void; -} +}; -const ListView: React.FC = ({ +const ListView = ({ items, isEmpty, isLoading, onScrollBottom, isReachingEnd, -}) => { + plexItems, +}: ListViewProps) => { const intl = useIntl(); useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd); return ( @@ -35,6 +38,18 @@ const ListView: React.FC = ({
)}
    + {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 be65f009..8f922ef3 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 a7224b22..288a0b37 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 c41935ae..01d3a012 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 64ca49c1..7df2b041 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 886fd721..6652f551 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 75158705..8ee46fea 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 00000000..0653c7d8 --- /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 9e0cb0ca..a286de69 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 00000000..82bc7a7a --- /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 b6383a77..762d1a08 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 e340f4eb..d31921da 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 b1e19d05..e9a274fa 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 cef4c623..b9ec8dea 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 247c5ece..f09fef37 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 b1f3b066..1d78748b 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 60c29225..404b1aa5 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 d4b672a5..9602fbb8 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 ed0873f9..b6c710e9 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 5b59f26a..2a693964 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 00000000..fbbdff01 --- /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 bc85adad..f19f5770 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 cf1b8ce1..4899d349 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 61468a6f..8973cbd1 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 59f0e8c0..3f136142 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 c0f2e222..5210e7d3 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 15fe9a01..391c51f2 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 54f8daa3..820012c3 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 1e14f73d..b556e6f9 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 3ebd6226..24dc6fea 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 af49fda9..d7c4b460 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 11623f53..b941c9f3 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 5dbc4180..0ed4162f 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 92cf6bc7..7552c633 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 f3f226de..6ec67c24 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 1d610604..0eec6b7d 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 00000000..abc08dd1 --- /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 e51fcabf..57481b71 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 fe621d2a..ec32ecc6 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 d682df4c..515ff20e 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 b560c66e..2e63441e 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 712ba4db..1e488c67 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 4cd271b9..01bc8123 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 e76a1650..5bd38301 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 b97ac6bd..fae793d1 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 eb8f368b..3c16bdcf 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -1,21 +1,21 @@ +import Accordion from '@app/components/Common/Accordion'; +import ImageFader from '@app/components/Common/ImageFader'; +import PageTitle from '@app/components/Common/PageTitle'; +import LanguagePicker from '@app/components/Layout/LanguagePicker'; +import LocalLogin from '@app/components/Login/LocalLogin'; +import PlexLoginButton from '@app/components/PlexLoginButton'; +import useSettings from '@app/hooks/useSettings'; +import { useUser } from '@app/hooks/useUser'; +import { Transition } from '@headlessui/react'; import { XCircleIcon } from '@heroicons/react/solid'; +import { MediaServerType } from '@server/constants/server'; import axios from 'axios'; +import getConfig from 'next/config'; import { useRouter } from 'next/dist/client/router'; -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import { MediaServerType } from '../../../server/constants/server'; -import useSettings from '../../hooks/useSettings'; -import { useUser } from '../../hooks/useUser'; -import Accordion from '../Common/Accordion'; -import ImageFader from '../Common/ImageFader'; -import PageTitle from '../Common/PageTitle'; -import LanguagePicker from '../Layout/LanguagePicker'; -import PlexLoginButton from '../PlexLoginButton'; -import Transition from '../Transition'; import JellyfinLogin from './JellyfinLogin'; -import LocalLogin from './LocalLogin'; -import getConfig from 'next/config'; const messages = defineMessages({ signin: 'Sign In', @@ -25,7 +25,7 @@ const messages = defineMessages({ signinwithoverseerr: 'Use your {applicationTitle} account', }); -const Login: React.FC = () => { +const Login = () => { const intl = useIntl(); const [error, setError] = useState(''); const [isProcessing, setProcessing] = useState(false); @@ -78,7 +78,7 @@ const Login: React.FC = () => { `https://www.themoviedb.org/t/p/original${backdrop}` + (backdrop) => `https://image.tmdb.org/t/p/original${backdrop}` ) ?? [] } /> @@ -98,6 +98,7 @@ const Login: React.FC = () => { > <> = ({ show, mediaType, onClose, data, revalidate }) => { +const ManageSlideOver = ({ + show, + mediaType, + onClose, + data, + revalidate, +}: ManageSlideOverMovieProps | ManageSlideOverTvProps) => { const { user: currentUser, hasPermission } = useUser(); const intl = useIntl(); const settings = useSettings(); 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 })} + {!!watchData?.data && ( +
        +
        +
        +
        + {intl.formatMessage(messages.pastdays, { + days: 7, + })} +
        +
        + {styledPlayCount(watchData.data.playCount7Days)} +
        -
        - {styledPlayCount(watchData.data.playCount7Days)} +
        +
        + {intl.formatMessage(messages.pastdays, { + days: 30, + })} +
        +
        + {styledPlayCount(watchData.data.playCount30Days)} +
        +
        +
        +
        + {intl.formatMessage(messages.alltime)} +
        +
        + {styledPlayCount(watchData.data.playCount)} +
        -
        -
        - {intl.formatMessage(messages.pastdays, { - days: 30, - })} + {!!watchData.data.users.length && ( +
        + + {intl.formatMessage(messages.playedby)} + + + {watchData.data.users.map((user) => ( + + + {user.displayName} + + + ))} +
        -
        - {styledPlayCount(watchData.data.playCount30Days)} -
        -
        -
        -
        - {intl.formatMessage(messages.alltime)} -
        -
        - {styledPlayCount(watchData.data.playCount)} -
        -
        + )}
        - {!!watchData.data.users.length && ( -
        - - {intl.formatMessage(messages.playedby)} - - - {watchData.data.users.map((user) => ( - - - {user.displayName} - - - ))} - -
        - )} -
        + )} {data.mediaInfo?.tautulliUrl && ( @@ -302,7 +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 })} + {watchData?.data4k && ( +
        +
        +
        +
        + {intl.formatMessage(messages.pastdays, { + days: 7, + })} +
        +
        + {styledPlayCount(watchData.data4k.playCount7Days)} +
        -
        - {styledPlayCount(watchData.data4k.playCount7Days)} +
        +
        + {intl.formatMessage(messages.pastdays, { + days: 30, + })} +
        +
        + {styledPlayCount( + watchData.data4k.playCount30Days + )} +
        +
        +
        +
        + {intl.formatMessage(messages.alltime)} +
        +
        + {styledPlayCount(watchData.data4k.playCount)} +
        -
        -
        - {intl.formatMessage(messages.pastdays, { - days: 30, - })} + {!!watchData.data4k.users.length && ( +
        + + {intl.formatMessage(messages.playedby)} + + + {watchData.data4k.users.map((user) => ( + + + {user.displayName} + + + ))} +
        -
        - {styledPlayCount(watchData.data4k.playCount30Days)} -
        -
        -
        -
        - {intl.formatMessage(messages.alltime)} -
        -
        - {styledPlayCount(watchData.data4k.playCount)} -
        -
        + )}
        - {!!watchData.data4k.users.length && ( -
        - )} -
        + )} {data.mediaInfo?.tautulliUrl4k && ( @@ -487,7 +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 f6bc2ccb..99900ac9 100644 --- a/src/components/MediaSlider/ShowMoreCard/index.tsx +++ b/src/components/MediaSlider/ShowMoreCard/index.tsx @@ -1,6 +1,6 @@ import { ArrowCircleRightIcon } from '@heroicons/react/solid'; import Link from 'next/link'; -import React, { useState } from 'react'; +import { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; const messages = defineMessages({ @@ -12,7 +12,7 @@ interface ShowMoreCardProps { posters: (string | undefined)[]; } -const ShowMoreCard: React.FC = ({ url, posters }) => { +const ShowMoreCard = ({ url, posters }: ShowMoreCardProps) => { const intl = useIntl(); const [isHovered, setHovered] = useState(false); return ( diff --git a/src/components/MediaSlider/index.tsx b/src/components/MediaSlider/index.tsx index 84c72822..9a9bc054 100644 --- a/src/components/MediaSlider/index.tsx +++ b/src/components/MediaSlider/index.tsx @@ -1,18 +1,18 @@ +import ShowMoreCard from '@app/components/MediaSlider/ShowMoreCard'; +import PersonCard from '@app/components/PersonCard'; +import Slider from '@app/components/Slider'; +import TitleCard from '@app/components/TitleCard'; +import useSettings from '@app/hooks/useSettings'; import { ArrowCircleRightIcon } from '@heroicons/react/outline'; -import Link from 'next/link'; -import React, { useEffect } from 'react'; -import useSWRInfinite from 'swr/infinite'; -import { MediaStatus } from '../../../server/constants/media'; +import { MediaStatus } from '@server/constants/media'; import type { MovieResult, PersonResult, TvResult, -} from '../../../server/models/Search'; -import useSettings from '../../hooks/useSettings'; -import PersonCard from '../PersonCard'; -import Slider from '../Slider'; -import TitleCard from '../TitleCard'; -import ShowMoreCard from './ShowMoreCard'; +} from '@server/models/Search'; +import Link from 'next/link'; +import { useEffect } from 'react'; +import useSWRInfinite from 'swr/infinite'; interface MixedResult { page: number; @@ -29,13 +29,13 @@ interface MediaSliderProps { hideWhenEmpty?: boolean; } -const MediaSlider: React.FC = ({ +const MediaSlider = ({ title, url, linkUrl, sliderKey, hideWhenEmpty = false, -}) => { +}: MediaSliderProps) => { const settings = useSettings(); const { data, error, setSize, size } = useSWRInfinite( (pageIndex: number, previousPageData: MixedResult | null) => { diff --git a/src/components/MovieDetails/MovieCast/index.tsx b/src/components/MovieDetails/MovieCast/index.tsx index 0cc9c2e0..2006e9df 100644 --- a/src/components/MovieDetails/MovieCast/index.tsx +++ b/src/components/MovieDetails/MovieCast/index.tsx @@ -1,20 +1,19 @@ +import Header from '@app/components/Common/Header'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import PersonCard from '@app/components/PersonCard'; +import Error from '@app/pages/_error'; +import type { MovieDetails } from '@server/models/Movie'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import { MovieDetails } from '../../../../server/models/Movie'; -import Error from '../../../pages/_error'; -import Header from '../../Common/Header'; -import LoadingSpinner from '../../Common/LoadingSpinner'; -import PageTitle from '../../Common/PageTitle'; -import PersonCard from '../../PersonCard'; const messages = defineMessages({ fullcast: 'Full Cast', }); -const MovieCast: React.FC = () => { +const MovieCast = () => { const router = useRouter(); const intl = useIntl(); const { data, error } = useSWR( diff --git a/src/components/MovieDetails/MovieCrew/index.tsx b/src/components/MovieDetails/MovieCrew/index.tsx index 14268e42..1cc43b05 100644 --- a/src/components/MovieDetails/MovieCrew/index.tsx +++ b/src/components/MovieDetails/MovieCrew/index.tsx @@ -1,20 +1,19 @@ +import Header from '@app/components/Common/Header'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import PersonCard from '@app/components/PersonCard'; +import Error from '@app/pages/_error'; +import type { MovieDetails } from '@server/models/Movie'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import { MovieDetails } from '../../../../server/models/Movie'; -import Error from '../../../pages/_error'; -import Header from '../../Common/Header'; -import LoadingSpinner from '../../Common/LoadingSpinner'; -import PageTitle from '../../Common/PageTitle'; -import PersonCard from '../../PersonCard'; const messages = defineMessages({ fullcrew: 'Full Crew', }); -const MovieCrew: React.FC = () => { +const MovieCrew = () => { const router = useRouter(); const intl = useIntl(); const { data, error } = useSWR( diff --git a/src/components/MovieDetails/MovieRecommendations.tsx b/src/components/MovieDetails/MovieRecommendations.tsx index fc9c2bf2..a7635a25 100644 --- a/src/components/MovieDetails/MovieRecommendations.tsx +++ b/src/components/MovieDetails/MovieRecommendations.tsx @@ -1,21 +1,20 @@ +import Header from '@app/components/Common/Header'; +import ListView from '@app/components/Common/ListView'; +import PageTitle from '@app/components/Common/PageTitle'; +import useDiscover from '@app/hooks/useDiscover'; +import Error from '@app/pages/_error'; +import type { MovieDetails } from '@server/models/Movie'; +import type { MovieResult } from '@server/models/Search'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import type { MovieDetails } from '../../../server/models/Movie'; -import type { MovieResult } from '../../../server/models/Search'; -import useDiscover from '../../hooks/useDiscover'; -import Error from '../../pages/_error'; -import Header from '../Common/Header'; -import ListView from '../Common/ListView'; -import PageTitle from '../Common/PageTitle'; const messages = defineMessages({ recommendations: 'Recommendations', }); -const MovieRecommendations: React.FC = () => { +const MovieRecommendations = () => { const intl = useIntl(); const router = useRouter(); const { data: movieData } = useSWR( diff --git a/src/components/MovieDetails/MovieSimilar.tsx b/src/components/MovieDetails/MovieSimilar.tsx index 8103f966..5ce5ef1a 100644 --- a/src/components/MovieDetails/MovieSimilar.tsx +++ b/src/components/MovieDetails/MovieSimilar.tsx @@ -1,21 +1,20 @@ +import Header from '@app/components/Common/Header'; +import ListView from '@app/components/Common/ListView'; +import PageTitle from '@app/components/Common/PageTitle'; +import useDiscover from '@app/hooks/useDiscover'; +import Error from '@app/pages/_error'; +import type { MovieDetails } from '@server/models/Movie'; +import type { MovieResult } from '@server/models/Search'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import type { MovieDetails } from '../../../server/models/Movie'; -import type { MovieResult } from '../../../server/models/Search'; -import useDiscover from '../../hooks/useDiscover'; -import Error from '../../pages/_error'; -import Header from '../Common/Header'; -import ListView from '../Common/ListView'; -import PageTitle from '../Common/PageTitle'; const messages = defineMessages({ similar: 'Similar Titles', }); -const MovieSimilar: React.FC = () => { +const MovieSimilar = () => { const router = useRouter(); const intl = useIntl(); const { data: movieData } = useSWR( diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 3e33c4b8..d963585b 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -1,3 +1,29 @@ +import RTAudFresh from '@app/assets/rt_aud_fresh.svg'; +import RTAudRotten from '@app/assets/rt_aud_rotten.svg'; +import RTFresh from '@app/assets/rt_fresh.svg'; +import RTRotten from '@app/assets/rt_rotten.svg'; +import TmdbLogo from '@app/assets/tmdb_logo.svg'; +import Button from '@app/components/Common/Button'; +import CachedImage from '@app/components/Common/CachedImage'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import type { PlayButtonLink } from '@app/components/Common/PlayButton'; +import PlayButton from '@app/components/Common/PlayButton'; +import Tooltip from '@app/components/Common/Tooltip'; +import ExternalLinkBlock from '@app/components/ExternalLinkBlock'; +import IssueModal from '@app/components/IssueModal'; +import ManageSlideOver from '@app/components/ManageSlideOver'; +import MediaSlider from '@app/components/MediaSlider'; +import PersonCard from '@app/components/PersonCard'; +import RequestButton from '@app/components/RequestButton'; +import Slider from '@app/components/Slider'; +import StatusBadge from '@app/components/StatusBadge'; +import useLocale from '@app/hooks/useLocale'; +import useSettings from '@app/hooks/useSettings'; +import { Permission, useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; +import Error from '@app/pages/_error'; +import { sortCrewPriority } from '@app/utils/creditHelpers'; import { ArrowCircleRightIcon, CloudIcon, @@ -11,44 +37,20 @@ import { ChevronDoubleDownIcon, ChevronDoubleUpIcon, } from '@heroicons/react/solid'; +import type { RTRating } from '@server/api/rottentomatoes'; +import { IssueStatus } from '@server/constants/issue'; +import { MediaStatus } from '@server/constants/media'; +import { MediaServerType } from '@server/constants/server'; +import type { MovieDetails as MovieDetailsType } from '@server/models/Movie'; import { hasFlag } from 'country-flag-icons'; import 'country-flag-icons/3x2/flags.css'; import { uniqBy } from 'lodash'; +import getConfig from 'next/config'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React, { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; -import type { RTRating } from '../../../server/api/rottentomatoes'; -import { IssueStatus } from '../../../server/constants/issue'; -import { MediaStatus } from '../../../server/constants/media'; -import { MediaServerType } from '../../../server/constants/server'; -import type { MovieDetails as MovieDetailsType } from '../../../server/models/Movie'; -import RTAudFresh from '../../assets/rt_aud_fresh.svg'; -import RTAudRotten from '../../assets/rt_aud_rotten.svg'; -import RTFresh from '../../assets/rt_fresh.svg'; -import RTRotten from '../../assets/rt_rotten.svg'; -import TmdbLogo from '../../assets/tmdb_logo.svg'; -import useLocale from '../../hooks/useLocale'; -import useSettings from '../../hooks/useSettings'; -import { Permission, useUser } from '../../hooks/useUser'; -import globalMessages from '../../i18n/globalMessages'; -import Error from '../../pages/_error'; -import { sortCrewPriority } from '../../utils/creditHelpers'; -import Button from '../Common/Button'; -import CachedImage from '../Common/CachedImage'; -import LoadingSpinner from '../Common/LoadingSpinner'; -import PageTitle from '../Common/PageTitle'; -import PlayButton, { PlayButtonLink } from '../Common/PlayButton'; -import ExternalLinkBlock from '../ExternalLinkBlock'; -import IssueModal from '../IssueModal'; -import ManageSlideOver from '../ManageSlideOver'; -import MediaSlider from '../MediaSlider'; -import PersonCard from '../PersonCard'; -import RequestButton from '../RequestButton'; -import Slider from '../Slider'; -import StatusBadge from '../StatusBadge'; -import getConfig from 'next/config'; const messages = defineMessages({ originaltitle: 'Original Title', @@ -78,13 +80,21 @@ const messages = defineMessages({ streamingproviders: 'Currently Streaming On', productioncountries: 'Production {countryCount, plural, one {Country} other {Countries}}', + theatricalrelease: 'Theatrical Release', + digitalrelease: 'Digital Release', + physicalrelease: 'Physical Release', + reportissue: 'Report an Issue', + managemovie: 'Manage Movie', + rtcriticsscore: 'Rotten Tomatoes Tomatometer', + rtaudiencescore: 'Rotten Tomatoes Audience Score', + tmdbuserscore: 'TMDB User Score', }); interface MovieDetailsProps { movie?: MovieDetailsType; } -const MovieDetails: React.FC = ({ movie }) => { +const MovieDetails = ({ movie }: MovieDetailsProps) => { const settings = useSettings(); const { user, hasPermission } = useUser(); const router = useRouter(); @@ -119,6 +129,32 @@ const MovieDetails: React.FC = ({ movie }) => { setShowManager(router.query.manage == '1' ? true : false); }, [router.query.manage]); + const [plexUrl, setPlexUrl] = useState(data?.mediaInfo?.mediaUrl); + const [plexUrl4k, setPlexUrl4k] = useState(data?.mediaInfo?.mediaUrl4k); + + useEffect(() => { + if (data) { + if ( + settings.currentSettings.mediaServerType === MediaServerType.PLEX && + (/iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.userAgent === 'MacIntel' && navigator.maxTouchPoints > 1)) + ) { + setPlexUrl(data.mediaInfo?.iOSPlexUrl); + setPlexUrl4k(data.mediaInfo?.iOSPlexUrl4k); + } else { + setPlexUrl(data.mediaInfo?.mediaUrl); + setPlexUrl4k(data.mediaInfo?.mediaUrl4k); + } + } + }, [ + data, + data?.mediaInfo?.iOSPlexUrl, + data?.mediaInfo?.iOSPlexUrl4k, + data?.mediaInfo?.mediaUrl, + data?.mediaInfo?.mediaUrl4k, + settings.currentSettings.mediaServerType, + ]); + if (!data && !error) { return ; } @@ -130,27 +166,32 @@ const MovieDetails: React.FC = ({ movie }) => { const showAllStudios = data.productionCompanies.length <= minStudios + 1; const mediaLinks: PlayButtonLink[] = []; - if (data.mediaInfo?.mediaUrl) { + if ( + plexUrl && + hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], { + type: 'or', + }) + ) { mediaLinks.push({ text: getAvalaibleMediaServerName(), - url: data.mediaInfo?.mediaUrl, + url: plexUrl, svg: , }); } if ( - data.mediaInfo?.mediaUrl4k && - hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], { + settings.currentSettings.movie4kEnabled && + plexUrl4k && + hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], { type: 'or', }) ) { mediaLinks.push({ text: getAvalaible4kMediaServerName(), - url: data.mediaInfo?.mediaUrl4k, + url: plexUrl4k, svg: , }); } - const trailerUrl = data.relatedVideos ?.filter((r) => r.type === 'Trailer') .sort((a, b) => a.size - b.size) @@ -315,7 +356,8 @@ const MovieDetails: React.FC = ({ movie }) => { inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0} tmdbId={data.mediaInfo?.tmdbId} mediaType="movie" - plexUrl={data.mediaInfo?.mediaUrl} + plexUrl={plexUrl} + serviceUrl={data.mediaInfo?.serviceUrl} /> {settings.currentSettings.movie4kEnabled && hasPermission( @@ -336,11 +378,12 @@ const MovieDetails: React.FC = ({ movie }) => { } tmdbId={data.mediaInfo?.tmdbId} mediaType="movie" - plexUrl={data.mediaInfo?.mediaUrl4k} + plexUrl={plexUrl} + serviceUrl={data.mediaInfo?.serviceUrl4k} /> )}
        -

        +

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

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