diff --git a/.all-contributorsrc b/.all-contributorsrc index 3671ef22..be1261d0 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -312,6 +312,70 @@ "contributions": [ "code" ] + }, + { + "login": "NuroDev", + "name": "nuro", + "avatar_url": "https://avatars.githubusercontent.com/u/4991309?v=4", + "profile": "https://nuro.dev", + "contributions": [ + "doc" + ] + }, + { + "login": "onedr0p", + "name": "ᗪєνιη ᗷυнʟ", + "avatar_url": "https://avatars.githubusercontent.com/u/213795?v=4", + "profile": "https://github.com/onedr0p", + "contributions": [ + "infra" + ] + }, + { + "login": "JonnyWong16", + "name": "JonnyWong16", + "avatar_url": "https://avatars.githubusercontent.com/u/9099342?v=4", + "profile": "https://github.com/JonnyWong16", + "contributions": [ + "doc" + ] + }, + { + "login": "Roxedus", + "name": "Roxedus", + "avatar_url": "https://avatars.githubusercontent.com/u/7110194?v=4", + "profile": "https://github.com/Roxedus", + "contributions": [ + "doc" + ] + }, + { + "login": "WoisWoi", + "name": "WoisWoi", + "avatar_url": "https://avatars.githubusercontent.com/u/75491231?v=4", + "profile": "https://github.com/WoisWoi", + "contributions": [ + "translation" + ] + }, + { + "login": "HubDuck", + "name": "HubDuck", + "avatar_url": "https://avatars.githubusercontent.com/u/77843475?v=4", + "profile": "https://github.com/HubDuck", + "contributions": [ + "translation" + ] + }, + { + "login": "costaht", + "name": "costaht", + "avatar_url": "https://avatars.githubusercontent.com/u/50637431?v=4", + "profile": "https://github.com/costaht", + "contributions": [ + "doc", + "translation" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffc74754..751a8c07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: test: name: Lint & Test Build runs-on: ubuntu-20.04 - container: node:12.18-alpine + container: node:14.16-alpine steps: - name: checkout uses: actions/checkout@v2 @@ -24,6 +24,7 @@ jobs: run: yarn lint - name: build run: yarn build + build_and_push: name: Build & Publish to Docker Hub needs: test @@ -59,6 +60,7 @@ jobs: with: context: . file: ./Dockerfile + platforms: linux/amd64,linux/arm64,linux/arm/v7 push: true build-args: | COMMIT_TAG=${{ github.sha }} @@ -68,7 +70,15 @@ jobs: ghcr.io/sct/overseerr:develop ghcr.io/sct/overseerr:${{ github.sha }} cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,dest=/tmp/.buildx-cache,mode=max + cache-to: type=local,dest=/tmp/.buildx-cache-new + - # Temporary fix + # https://github.com/docker/build-push-action/issues/252 + # https://github.com/moby/buildkit/issues/1896 + name: Move cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache + discord: name: Send Discord Notification needs: build_and_push @@ -76,8 +86,7 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Get Build Job Status - uses: technote-space/workflow-conclusion-action@v2.1.2 - + uses: technote-space/workflow-conclusion-action@v2.1.5 - name: Combine Job Status id: status run: | @@ -87,7 +96,6 @@ jobs: else echo ::set-output name=status::$WORKFLOW_CONCLUSION fi - - name: Post Status to Discord uses: sarisia/actions-status-discord@v1 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e929ce9..6ddccdd1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ jobs: test: name: Lint & Test Build runs-on: ubuntu-20.04 - container: node:12.18-alpine + container: node:14.16-alpine steps: - name: checkout uses: actions/checkout@v2 @@ -31,9 +31,24 @@ jobs: with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v2 with: - node-version: 12 + node-version: 14 + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + - name: Login to GitHub Container Registry + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.CR_PAT }} - name: Install dependencies run: yarn - name: Release @@ -108,7 +123,7 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Get Build Job Status - uses: technote-space/workflow-conclusion-action@v2.1.2 + uses: technote-space/workflow-conclusion-action@v2.1.5 - name: Combine Job Status id: status diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml index ac68a327..9fd27835 100644 --- a/.github/workflows/snap.yaml +++ b/.github/workflows/snap.yaml @@ -90,7 +90,7 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Get Build Job Status - uses: technote-space/workflow-conclusion-action@v2.1.2 + uses: technote-space/workflow-conclusion-action@v2.1.5 - name: Combine Job Status id: status diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a97a796..1c9b31dc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,80 +1,87 @@ # Contributing to Overseerr -All help is welcome and greatly appreciated. If you would like to contribute to the project, the instructions below can get you started... +All help is welcome and greatly appreciated! If you would like to contribute to the project, the following instructions should get you started... ## Development ### Tools Required -- HTML/Typescript/Javascript editor of choice. ([VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install.) -- [NodeJS](https://nodejs.org/en/download/) (Node 12.x.x or higher) +- HTML/Typescript/Javascript editor + - [VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install. +- [NodeJS](https://nodejs.org/en/download/) (Node 14.x or higher) - [Yarn](https://yarnpkg.com/) - [Git](https://git-scm.com/downloads) ### Getting Started -1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device. +1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device: ```bash git clone https://github.com/YOUR_USERNAME/overseerr.git cd overseerr/ ``` -2. Add the remote upstream. +2. Add the remote `upstream`: ```bash git remote add upstream https://github.com/sct/overseerr.git ``` -3. Create a new branch +3. Create a new branch: ```bash git checkout -b BRANCH_NAME develop ``` - - It is recommended to name the branch something relevant to the feature or fix you are working on. - - An example of this would be `fix-title-cards` or `feature-new-system`. - - Bad examples would be `patch` or `bug`. + - It is recommended to give your branch a meaningful name, relevant to the feature or fix you are working on. + - Good examples: + - `docs-docker` + - `feature-new-system` + - `fix-title-cards` + - Bad examples: + - `bug` + - `docs` + - `feature` + - `fix` + - `patch` -4. Run development environment +4. Run the development environment: ```bash yarn yarn dev ``` - - Alternatively you can run using [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly. + - Alternatively, you can use [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly. -5. Create your patch and run appropriate tests. +5. Create your patch and test your changes. -6. Follow the [guidelines](#contributing-code). - -7. Should you need to update your fork, you can do so by rebasing from `upstream`: - - ```bash - git fetch upstream - git rebase upstream/develop - git push origin BRANCH_NAME -f - ``` + - Be sure to follow both the [code](#contributing-code) and [UI text](#ui-text-style) guidelines. + - Should you need to update your fork, you can do so by rebasing from `upstream`: + ```bash + git fetch upstream + git rebase upstream/develop + git push origin BRANCH_NAME -f + ``` ### Contributing Code -- If you are taking on an existing bug or feature ticket, please comment on the [GitHub Issue](https://github.com/sct/overseerr/issues) to avoid multiple people working on the same thing. +- If you are taking on an existing bug or feature ticket, please comment on the [issue](https://github.com/sct/overseerr/issues) to avoid multiple people working on the same thing. - All commits **must** follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) - - It is okay if you squash your PR down to be a single commit that fits this standard. - - PRs with commits not following this standard will not be merged. + - It is okay to squash your pull request down into a single commit that fits this standard. + - Pull requests with commits not following this standard will **not** be merged. - Please make meaningful commits, or squash them. -- Always rebase your commit to the latest `develop` branch. Do not merge `develop` into your branch. -- It is your responsibility to keep your branch up to date. It will not be merged unless its rebased off the latest `develop` branch. +- Always rebase your commit to the latest `develop` branch. Do **not** merge `develop` into your branch. +- It is your responsibility to keep your branch up-to-date. Your work will **not** be merged unless it is rebased off the latest `develop` branch. - You can create a "draft" pull request early to get feedback on your work. -- Your code must be formatted correctly or the tests will fail. - - We use Prettier to format our codebase. It should automatically run with a `git` hook, but it is recommended to have the Prettier extension installed in your editor and format on save. -- If you have questions or need help, you can reach out in [GitHub Discussions](https://github.com/sct/overseerr/discussions) or in our [Discord](https://discord.gg/PkCWJSeCk7). -- Only open pull requests to `develop`. Never `master`. Any PRs opened to `master` will be closed. +- Your code **must** be formatted correctly, or the tests will fail. + - We use Prettier to format our code base. It should automatically run with a Git hook, but it is recommended to have the Prettier extension installed in your editor and format on save. +- If you have questions or need help, you can reach out via [Discussions](https://github.com/sct/overseerr/discussions) or our [Discord server](https://discord.gg/PkCWJSeCk7). +- Only open pull requests to `develop`, never `master`! Any pull requests opened to `master` will be closed. ### UI Text Style -When adding new UI text, please be sure to adhere to the following guidelines: +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. @@ -90,7 +97,7 @@ When adding new UI text, please be sure to adhere to the following guidelines: ## Translation -We use [Weblate](https://hosted.weblate.org/engage/overseerr/) for our translations, and your help with localizing Overseerr would be greatly appreciated! If your language is not listed below, please [open a feature request on GitHub](https://github.com/sct/overseerr/issues/new/choose). +We use [Weblate](https://hosted.weblate.org/engage/overseerr/) for our translations, and your help with localizing Overseerr would be greatly appreciated! If your language is not listed below, please [open a feature request](https://github.com/sct/overseerr/issues/new/choose). Translation status diff --git a/Dockerfile b/Dockerfile index 281ba10e..9a5af8ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,7 @@ -FROM node:14.15-alpine AS BUILD_IMAGE +FROM node:14.16-alpine AS BUILD_IMAGE + +ARG TARGETPLATFORM +ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64} ARG COMMIT_TAG ENV COMMIT_TAG=${COMMIT_TAG} @@ -6,7 +9,13 @@ ENV COMMIT_TAG=${COMMIT_TAG} COPY . /app WORKDIR /app -RUN yarn --frozen-lockfile && \ +RUN \ + case "${TARGETPLATFORM}" in \ + 'linux/arm64') apk add --no-cache python make g++ ;; \ + 'linux/arm/v7') apk add --no-cache python make g++ ;; \ + esac + +RUN yarn --frozen-lockfile --network-timeout 1000000 && \ yarn build # remove development dependencies @@ -20,14 +29,15 @@ RUN touch config/DOCKER RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json -FROM node:14.15-alpine +FROM node:14.16-alpine -RUN apk add --no-cache tzdata +RUN apk add --no-cache tzdata tini # copy from build image COPY --from=BUILD_IMAGE /app /app WORKDIR /app -CMD yarn start +ENTRYPOINT [ "/sbin/tini", "--" ] +CMD [ "yarn", "start" ] EXPOSE 5055 diff --git a/Dockerfile.local b/Dockerfile.local index 47470513..64fd61a6 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -1,4 +1,4 @@ -FROM node:12.18-alpine +FROM node:14.16-alpine COPY . /app WORKDIR /app diff --git a/README.md b/README.md index 70bd55d5..0fc140ed 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Language grade: JavaScript GitHub -All Contributors +All Contributors

@@ -47,6 +47,7 @@ Currently, Overseerr is primarily distributed as Docker images. If you have Dock ``` docker run -d \ + --name overseerr \ -e LOG_LEVEL=info \ -e TZ=Asia/Tokyo \ -p 5055:5055 \ @@ -57,7 +58,7 @@ docker run -d \ After running Overseerr for the first time, configure it by visiting the web UI at http://[address]:5055 and completing the setup steps -For more information or alternative installation methods, please see the [Overseerr documentation](https://docs.overseerr.dev/getting-started/installation). +For more information and alternative installation methods, please see the [Overseerr documentation](https://docs.overseerr.dev/getting-started/installation). ⚠️ Overseerr is currently under very heavy, rapid development and things are likely to break often. We need all the help we can get to find bugs and get them fixed to hit a more stable release. If you would like to help test the bleeding edge, please use the `sctx/overseerr:develop` image instead! ⚠️ @@ -140,6 +141,15 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
David

💻
Douglas Parker

📖
Daniel Carter

💻 +
nuro

📖 +
ᗪєνιη ᗷυнʟ

🚇 + + +
JonnyWong16

📖 +
Roxedus

📖 +
WoisWoi

🌍 +
HubDuck

🌍 +
costaht

📖 🌍 diff --git a/docs/extending-overseerr/reverse-proxy-examples.md b/docs/extending-overseerr/reverse-proxy-examples.md index 8fa120ee..659feb88 100644 --- a/docs/extending-overseerr/reverse-proxy-examples.md +++ b/docs/extending-overseerr/reverse-proxy-examples.md @@ -1,12 +1,12 @@ # Reverse Proxy Examples {% hint style="warning" %} -Base URLs cannot be configured in Overseerr. With this limitation, only subdomain configurations are supported. +Base URLs cannot be configured in Overseerr. With this limitation, only subdomain configurations are supported. However, a Nginx subfolder workaround configuration is provided below to use at your own risk. {% endhint %} -## [SWAG (Secure Web Application Gateway, formerly known as `letsencrypt`)](https://github.com/linuxserver/docker-swag) +## SWAG -A sample proxy configuration is included in SWAG. However, this page is still the only source of truth, so the SWAG sample configuration is not guaranteed to be up-to-date. If you find an inconsistency, please [report it to the LinuxServer team](https://github.com/linuxserver/reverse-proxy-confs/issues/new) or [submit a pull request to update it](https://github.com/linuxserver/reverse-proxy-confs/pulls). +A sample proxy configuration is included in [SWAG (Secure Web Application Gateway)](https://github.com/linuxserver/docker-swag). However, this page is still the only source of truth, so the SWAG sample configuration is not guaranteed to be up-to-date. If you find an inconsistency, please [report it to the LinuxServer team](https://github.com/linuxserver/reverse-proxy-confs/issues/new) or [submit a pull request to update it](https://github.com/linuxserver/reverse-proxy-confs/pulls). To use the bundled configuration file, simply rename `overseerr.subdomain.conf.sample` in the `proxy-confs` folder to `overseerr.subdomain.conf`. Alternatively, create a new file `overseerr.subdomain.conf` in `proxy-confs` with the following configuration: @@ -53,11 +53,14 @@ labels: For more information, see the Traefik documentation for a [basic example](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/). -## `nginx` +## Nginx + +{% tabs %} +{% tab title="Subdomain" %} Add the following configuration to a new file `/etc/nginx/sites-available/overseerr.example.com.conf`: -```text +```nginx server { listen 80; server_name overseerr.example.com; @@ -111,6 +114,46 @@ Then, create a symlink to `/etc/nginx/sites-enabled`: ```bash sudo ln -s /etc/nginx/sites-available/overseerr.example.com.conf /etc/nginx/sites-enabled/overseerr.example.com.conf ``` +{% endtab %} + +{% tab title="Subfolder" %} + +{% hint style="warning" %} +Nginx subfolder reverse proxy is unsupported. The sub filters may stop working when Overseerr is updated. Use at your own risk! +{% endhint %} + +Add the following location block to your existing `nginx.conf` file. + +```nginx +location ^~ /overseerr { + set $app 'overseerr'; + # Remove /overseerr path to pass to the app + rewrite ^/overseerr/?(.*)$ /$1 break; + proxy_pass http://127.0.0.1:5055; # NO TRAILING SLASH + # Redirect location headers + proxy_redirect ^ /$app; + proxy_redirect /setup /$app/setup; + proxy_redirect /login /$app/login; + # Sub filters to replace hardcoded paths + proxy_set_header Accept-Encoding ""; + sub_filter_once off; + sub_filter_types *; + sub_filter 'href="/"' 'href="/$app"'; + sub_filter 'href="/login"' 'href="/$app/login"'; + sub_filter 'href:"/"' 'href:"/$app"'; + sub_filter '/_next' '/$app/_next'; + sub_filter '/api/v1' '/$app/api/v1'; + sub_filter '/login/plex/loading' '/$app/login/plex/loading'; + sub_filter '/images/' '/$app/images/'; + sub_filter '/android-' '/$app/android-'; + sub_filter '/apple-' '/$app/apple-'; + sub_filter '/favicon' '/$app/favicon'; + sub_filter '/logo.png' '/$app/logo.png'; + sub_filter '/site.webmanifest' '/$app/site.webmanifest'; +} +``` +{% endtab %} +{% endtabs %} Next, test the configuration: diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index b0efe68d..88e213fd 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -15,6 +15,7 @@ After running Overseerr for the first time, configure it by visiting the web UI ```bash docker run -d \ + --name overseerr \ -e LOG_LEVEL=info \ -e TZ=Asia/Tokyo \ -p 5055:5055 \ @@ -25,10 +26,35 @@ docker run -d \ {% endtab %} +{% tab title="Compose" %} + +**docker-compose.yml:** + +```yaml +--- +version: "3" + +services: + overseerr: + image: sctx/overseerr:latest + container_name: overseerr + environment: + - LOG_LEVEL=info + - TZ=Asia/Tokyo + ports: + - 5055:5055 + volumes: + - /path/to/appdata/config:/app/config + restart: unless-stopped +``` + +{% endtab %} + {% tab title="UID/GID" %} ```text docker run -d \ + --name overseerr \ --user=[ user | user:group | uid | uid:gid | user:gid | uid:group ] \ -e LOG_LEVEL=info \ -e TZ=Asia/Tokyo \ diff --git a/docs/using-overseerr/notifications/webhooks.md b/docs/using-overseerr/notifications/webhooks.md index 7adbc24a..68f54683 100644 --- a/docs/using-overseerr/notifications/webhooks.md +++ b/docs/using-overseerr/notifications/webhooks.md @@ -35,6 +35,7 @@ These variables are usually the target user of the notification. - `{{notifyuser_email}}` Target user's email. - `{{notifyuser_avatar}}` Target user's avatar. - `{{notifyuser_settings_discordId}}` Target user's discord ID (if one is set). +- `{{notifyuser_settings_telegramChatId}}` Target user's telegram Chat ID (if one is set). ### Media diff --git a/overseerr-api.yml b/overseerr-api.yml index b4047e52..b75b9b21 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -97,6 +97,10 @@ components: default: true discordId: type: string + telegramChatId: + type: string + telegramSendSilently: + type: boolean required: - enableNotifications MainSettings: @@ -574,6 +578,19 @@ components: type: string name: type: string + Network: + type: object + properties: + id: + type: number + example: 1 + logoPath: + type: string + nullable: true + originCountry: + type: string + name: + type: string RelatedVideo: type: object properties: @@ -887,6 +904,8 @@ components: $ref: '#/components/schemas/Season' status: type: string + tagline: + type: string type: type: string voteAverage: @@ -1085,6 +1104,10 @@ components: options: type: object properties: + botUsername: + type: string + botAvatarUrl: + type: string webhookUrl: type: string SlackSettings: @@ -1129,10 +1152,14 @@ components: options: type: object properties: + botUsername: + type: string botAPI: type: string chatId: type: string + sendSilently: + type: boolean PushbulletSettings: type: object properties: @@ -1171,9 +1198,6 @@ components: enabled: type: boolean example: true - autoapprovalEnabled: - type: boolean - example: false NotificationEmailSettings: type: object properties: @@ -1532,6 +1556,12 @@ components: discordId: type: string nullable: true + telegramChatId: + type: string + nullable: true + telegramSendSilently: + type: boolean + nullable: true required: - enableNotifications securitySchemes: @@ -1693,13 +1723,13 @@ paths: $ref: '#/components/schemas/PlexLibrary' /settings/plex/sync: get: - summary: Get status of full Plex library sync - description: Returns sync progress in a JSON array. + summary: Get status of full Plex library scan + description: Returns scan progress in a JSON array. tags: - settings responses: '200': - description: Status of Plex sync + description: Status of Plex scan content: application/json: schema: @@ -1721,8 +1751,8 @@ paths: items: $ref: '#/components/schemas/PlexLibrary' post: - summary: Start full Plex library sync - description: Runs a full Plex library sync and returns the progress in a JSON array. + summary: Start full Plex library scan + description: Runs a full Plex library scan and returns the progress in a JSON array. tags: - settings requestBody: @@ -1739,7 +1769,7 @@ paths: example: false responses: '200': - description: Status of Plex sync + description: Status of Plex scan content: application/json: schema: @@ -3223,6 +3253,16 @@ paths: schema: type: string example: en + - in: query + name: genre + schema: + type: number + example: 18 + - in: query + name: studio + schema: + type: number + example: 1 responses: '200': description: Results @@ -3244,6 +3284,147 @@ paths: type: array items: $ref: '#/components/schemas/MovieResult' + /discover/movies/genre/{genreId}: + get: + summary: Discover movies by genre + description: Returns a list of movies based on the provided genre ID in a JSON object. + tags: + - search + parameters: + - in: path + name: genreId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + genre: + $ref: '#/components/schemas/Genre' + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /discover/movies/language/{language}: + get: + summary: Discover movies by original language + description: Returns a list of movies based on the provided ISO 639-1 language code in a JSON object. + tags: + - search + parameters: + - in: path + name: language + required: true + schema: + type: string + example: en + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + language: + $ref: '#/components/schemas/SpokenLanguage' + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /discover/movies/studio/{studioId}: + get: + summary: Discover movies by studio + description: Returns a list of movies based on the provided studio ID in a JSON object. + tags: + - search + parameters: + - in: path + name: studioId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + studio: + $ref: '#/components/schemas/ProductionCompany' + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' /discover/movies/upcoming: get: summary: Upcoming movies @@ -3301,6 +3482,16 @@ paths: schema: type: string example: en + - in: query + name: genre + schema: + type: number + example: 18 + - in: query + name: network + schema: + type: number + example: 1 responses: '200': description: Results @@ -3322,6 +3513,147 @@ paths: type: array items: $ref: '#/components/schemas/TvResult' + /discover/tv/language/{language}: + get: + summary: Discover TV shows by original language + description: Returns a list of TV shows based on the provided ISO 639-1 language code in a JSON object. + tags: + - search + parameters: + - in: path + name: language + required: true + schema: + type: string + example: en + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + language: + $ref: '#/components/schemas/SpokenLanguage' + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /discover/tv/genre/{genreId}: + get: + summary: Discover TV shows by genre + description: Returns a list of TV shows based on the provided genre ID in a JSON object. + tags: + - search + parameters: + - in: path + name: genreId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + genre: + $ref: '#/components/schemas/Genre' + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /discover/tv/network/{networkId}: + get: + summary: Discover TV shows by network + description: Returns a list of TV shows based on the provided network ID in a JSON object. + tags: + - search + parameters: + - in: path + name: networkId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + network: + $ref: '#/components/schemas/Network' + results: + type: array + items: + $ref: '#/components/schemas/TvResult' /discover/tv/upcoming: get: summary: Discover Upcoming TV shows @@ -4326,14 +4658,16 @@ paths: content: application/json: schema: - type: object - properties: - iso_3166_1: - type: string - example: US - english_name: - type: string - example: United States of America + type: array + items: + type: object + properties: + iso_3166_1: + type: string + example: US + english_name: + type: string + example: United States of America /languages: get: summary: Languages supported by TMDb @@ -4346,17 +4680,115 @@ paths: content: application/json: schema: - type: object - properties: - iso_639_1: - type: string - example: en - english_name: - type: string - example: English - name: - type: string - example: English + type: array + items: + type: object + properties: + iso_639_1: + type: string + example: en + english_name: + type: string + example: English + name: + type: string + example: English + /studio/{studioId}: + get: + summary: Get movie studio details + description: Returns movie studio details in a JSON object. + tags: + - tmdb + parameters: + - in: path + name: studioId + required: true + schema: + type: number + example: 2 + responses: + '200': + description: Movie studio details + content: + application/json: + schema: + $ref: '#/components/schemas/ProductionCompany' + /network/{networkId}: + get: + summary: Get TV network details + description: Returns TV network details in a JSON object. + tags: + - tmdb + parameters: + - in: path + name: networkId + required: true + schema: + type: number + example: 1 + responses: + '200': + description: TV network details + content: + application/json: + schema: + $ref: '#/components/schemas/ProductionCompany' + /genres/movie: + get: + summary: Get list of official TMDb movie genres + description: Returns a list of genres in a JSON array. + tags: + - tmdb + parameters: + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: number + example: 10751 + name: + type: string + example: Family + /genres/tv: + get: + summary: Get list of official TMDb movie genres + description: Returns a list of genres in a JSON array. + tags: + - tmdb + parameters: + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: number + example: 18 + name: + type: string + example: Drama security: - cookieAuth: [] diff --git a/package.json b/package.json index b3e6b571..bad21c7d 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,10 @@ "@headlessui/react": "^0.3.1", "@supercharge/request-ip": "^1.1.2", "@svgr/webpack": "^5.5.0", + "@tanem/react-nprogress": "^3.0.57", "ace-builds": "^1.4.12", "axios": "^0.21.1", - "bcrypt": "^5.0.0", + "bcrypt": "^5.0.1", "body-parser": "^1.19.0", "bowser": "^2.11.0", "connect-typeorm": "^1.1.4", @@ -31,7 +32,7 @@ "csurf": "^1.11.0", "email-templates": "^8.0.3", "express": "^4.17.1", - "express-openapi-validator": "^4.11.0", + "express-openapi-validator": "^4.12.4", "express-session": "^1.17.1", "formik": "^2.2.6", "gravatar-url": "^3.1.0", @@ -40,16 +41,17 @@ "next": "10.0.3", "node-cache": "^5.1.2", "node-schedule": "^2.0.0", - "nodemailer": "^6.4.18", + "nodemailer": "^6.5.0", "nookies": "^2.5.2", + "openpgp": "^5.0.0-1", "plex-api": "^5.3.1", - "pug": "^3.0.0", + "pug": "^3.0.2", "react": "17.0.1", "react-ace": "^9.3.0", "react-animate-height": "^2.0.23", "react-dom": "17.0.1", "react-intersection-observer": "^8.31.0", - "react-intl": "^5.12.5", + "react-intl": "^5.13.2", "react-markdown": "^5.0.3", "react-spring": "^8.0.27", "react-toast-notifications": "^2.4.3", @@ -60,19 +62,19 @@ "secure-random-password": "^0.2.2", "sqlite3": "^5.0.2", "swagger-ui-express": "^4.1.6", - "swr": "^0.4.2", + "swr": "^0.5.1", "typeorm": "^0.2.31", "uuid": "^8.3.2", "winston": "^3.3.3", - "winston-daily-rotate-file": "^4.5.0", + "winston-daily-rotate-file": "^4.5.1", "xml2js": "^0.4.23", "yamljs": "^0.3.0", "yup": "^0.32.9" }, "devDependencies": { - "@babel/cli": "^7.12.17", - "@commitlint/cli": "^11.0.0", - "@commitlint/config-conventional": "^11.0.0", + "@babel/cli": "^7.13.10", + "@commitlint/cli": "^12.0.1", + "@commitlint/config-conventional": "^12.0.1", "@semantic-release/changelog": "^5.0.1", "@semantic-release/commit-analyzer": "^8.0.1", "@semantic-release/exec": "^5.0.0", @@ -89,11 +91,11 @@ "@types/express": "^4.17.11", "@types/express-session": "^1.17.3", "@types/lodash": "^4.14.168", - "@types/node": "^14.14.31", + "@types/node": "^14.14.34", "@types/node-schedule": "^1.3.1", - "@types/nodemailer": "^6.4.0", - "@types/react": "^17.0.2", - "@types/react-dom": "^17.0.1", + "@types/nodemailer": "^6.4.1", + "@types/react": "^17.0.3", + "@types/react-dom": "^17.0.2", "@types/react-toast-notifications": "^2.4.0", "@types/react-transition-group": "^4.4.1", "@types/secure-random-password": "^0.2.0", @@ -102,17 +104,17 @@ "@types/xml2js": "^0.4.8", "@types/yamljs": "^0.2.31", "@types/yup": "^0.29.11", - "@typescript-eslint/eslint-plugin": "^4.15.1", - "@typescript-eslint/parser": "^4.15.1", - "autoprefixer": "^10.2.4", + "@typescript-eslint/eslint-plugin": "^4.17.0", + "@typescript-eslint/parser": "^4.17.0", + "autoprefixer": "^10.2.5", "babel-plugin-react-intl": "^8.2.25", "babel-plugin-react-intl-auto": "^3.3.0", "commitizen": "^4.2.3", "copyfiles": "^2.4.1", "cz-conventional-changelog": "^3.3.0", - "eslint": "^7.20.0", + "eslint": "^7.21.0", "eslint-config-prettier": "^7.2.0", - "eslint-plugin-formatjs": "^2.12.4", + "eslint-plugin-formatjs": "^2.12.7", "eslint-plugin-jsx-a11y": "^6.4.1", "eslint-plugin-prettier": "^3.3.1", "eslint-plugin-react": "^7.22.0", @@ -121,14 +123,14 @@ "husky": "4.3.8", "lint-staged": "^10.5.4", "nodemon": "^2.0.7", - "postcss": "^8.2.6", + "postcss": "^8.2.8", "postcss-preset-env": "^6.7.0", "prettier": "^2.2.1", - "semantic-release": "^17.3.9", - "semantic-release-docker": "^2.2.0", + "semantic-release": "^17.4.2", + "semantic-release-docker-buildx": "^1.0.1", "tailwindcss": "npm:@tailwindcss/postcss7-compat", "ts-node": "^9.1.1", - "typescript": "^4.1.5" + "typescript": "^4.2.3" }, "resolutions": { "sqlite3/node-gyp": "^5.1.0" @@ -186,13 +188,7 @@ "message": "chore(release): ${nextRelease.version}" } ], - [ - "@semantic-release/exec", - { - "prepareCmd": "docker build --build-arg COMMIT_TAG=$GITHUB_SHA -t sctx/overseerr ." - } - ], - "semantic-release-docker", + "semantic-release-docker-buildx", [ "@semantic-release/github", { @@ -206,8 +202,19 @@ "npmPublish": false, "publish": [ { - "path": "semantic-release-docker", - "name": "sctx/overseerr" + "path": "semantic-release-docker-buildx", + "buildArgs": { + "COMMIT_TAG": "$GITHUB_SHA" + }, + "imageNames": [ + "sctx/overseerr", + "ghcr.io/sct/overseerr" + ], + "platforms": [ + "linux/amd64", + "linux/arm64", + "linux/arm/v7" + ] }, "@semantic-release/github" ] diff --git a/public/images/radarr_logo.svg b/public/images/radarr_logo.svg index 3ccb70e9..4af99613 100644 --- a/public/images/radarr_logo.svg +++ b/public/images/radarr_logo.svg @@ -1 +1 @@ - +image/svg+xml diff --git a/server/api/sonarr.ts b/server/api/sonarr.ts index 7369b0b6..e2e8bd19 100644 --- a/server/api/sonarr.ts +++ b/server/api/sonarr.ts @@ -207,11 +207,6 @@ class SonarrAPI extends ExternalAPI { if (series.id) { series.seasons = this.buildSeasonList(options.seasons, series.seasons); - series.addOptions = { - ignoreEpisodesWithFiles: true, - searchForMissingEpisodes: options.searchNow, - }; - const newSeriesResponse = await this.axios.put( '/series', series @@ -225,6 +220,9 @@ class SonarrAPI extends ExternalAPI { label: 'Sonarr', movie: newSeriesResponse.data, }); + if (options.searchNow) { + this.searchSeries(newSeriesResponse.data.id); + } } else { logger.error('Failed to update series in Sonarr', { label: 'Sonarr', @@ -350,6 +348,33 @@ class SonarrAPI extends ExternalAPI { } } + public async searchSeries(seriesId: number): Promise { + logger.info('Executing series search command', { + label: 'Sonarr API', + seriesId, + }); + await this.runCommand('SeriesSearch', { seriesId }); + } + + private async runCommand( + commandName: string, + options: Record + ): Promise { + try { + await this.axios.post(`/command`, { + name: commandName, + ...options, + }); + } catch (e) { + logger.error('Something went wrong attempting to run a Sonarr command.', { + label: 'Sonarr API', + message: e.message, + }); + + throw new Error('Failed to run Sonarr command.'); + } + } + private buildSeasonList( seasons: number[], existingSeasons?: SonarrSeason[] diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index b7bfeb92..e98ebb7e 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -4,8 +4,11 @@ import ExternalAPI from '../externalapi'; import { TmdbCollection, TmdbExternalIdResponse, + TmdbGenre, + TmdbGenresResult, TmdbLanguage, TmdbMovieDetails, + TmdbNetwork, TmdbPersonCombinedCredits, TmdbPersonDetail, TmdbRegion, @@ -15,6 +18,7 @@ import { TmdbSeasonWithEpisodes, TmdbTvDetails, TmdbUpcomingMoviesResponse, + TmdbProductionCompany, } from './interfaces'; interface SearchOptions { @@ -30,6 +34,9 @@ interface DiscoverMovieOptions { language?: string; primaryReleaseDateGte?: string; primaryReleaseDateLte?: string; + originalLanguage?: string; + genre?: number; + studio?: number; sortBy?: | 'popularity.asc' | 'popularity.desc' @@ -53,6 +60,9 @@ interface DiscoverTvOptions { firstAirDateGte?: string; firstAirDateLte?: string; includeEmptyReleaseDate?: boolean; + originalLanguage?: string; + genre?: number; + network?: number; sortBy?: | 'popularity.asc' | 'popularity.desc' @@ -120,7 +130,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}`); } }; @@ -142,7 +152,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}` ); } }; @@ -168,7 +178,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}`); } }; @@ -194,7 +204,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}`); } }; @@ -220,7 +230,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}`); } }; @@ -246,7 +256,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}`); } } @@ -272,7 +282,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}`); } } @@ -298,7 +308,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}`); } } @@ -325,7 +335,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}` ); } } @@ -349,7 +359,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}`); } } @@ -360,6 +370,9 @@ class TheMovieDb extends ExternalAPI { language = 'en', primaryReleaseDateGte, primaryReleaseDateLte, + originalLanguage, + genre, + studio, }: DiscoverMovieOptions = {}): Promise => { try { const data = await this.get('/discover/movie', { @@ -368,17 +381,18 @@ class TheMovieDb extends ExternalAPI { page, include_adult: includeAdult, language, - with_release_type: '3|2', region: this.region, - with_original_language: this.originalLanguage, + with_original_language: originalLanguage ?? this.originalLanguage, 'primary_release_date.gte': primaryReleaseDateGte, 'primary_release_date.lte': primaryReleaseDateLte, + with_genres: genre, + with_companies: studio, }, }); 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}`); } }; @@ -389,6 +403,9 @@ class TheMovieDb extends ExternalAPI { firstAirDateGte, firstAirDateLte, includeEmptyReleaseDate = false, + originalLanguage, + genre, + network, }: DiscoverTvOptions = {}): Promise => { try { const data = await this.get('/discover/tv', { @@ -399,14 +416,16 @@ class TheMovieDb extends ExternalAPI { region: this.region, 'first_air_date.gte': firstAirDateGte, 'first_air_date.lte': firstAirDateLte, - with_original_language: this.originalLanguage, + with_original_language: originalLanguage ?? this.originalLanguage, include_null_first_air_dates: includeEmptyReleaseDate, + with_genres: genre, + with_networks: network, }, }); 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}`); } }; @@ -432,7 +451,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}`); } }; @@ -459,7 +478,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}`); } }; @@ -482,7 +501,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}`); } }; @@ -505,7 +524,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}`); } }; @@ -537,7 +556,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}`); } } @@ -564,11 +583,11 @@ class TheMovieDb extends ExternalAPI { } throw new Error( - '[TMDB] Failed to find a title with the provided IMDB id' + '[TMDb] Failed to find a title with the provided IMDB id' ); } catch (e) { throw new Error( - `[TMDB] Failed to get movie by external imdb ID: ${e.message}` + `[TMDb] Failed to get movie by external imdb ID: ${e.message}` ); } } @@ -596,11 +615,11 @@ class TheMovieDb extends ExternalAPI { } throw new Error( - `[TMDB] Failed to find a TV show with the provided TVDB ID: ${tvdbId}` + `[TMDb] Failed to find a TV show with the provided TVDB 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}` ); } } @@ -624,7 +643,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}`); } } @@ -640,7 +659,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}`); } } @@ -656,7 +675,77 @@ 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}`); + } + } + + public async getStudio(studioId: number): Promise { + try { + const data = await this.get( + `/company/${studioId}` + ); + + return data; + } catch (e) { + throw new Error(`[TMDb] Failed to fetch movie studio: ${e.message}`); + } + } + + public async getNetwork(networkId: number): Promise { + try { + const data = await this.get(`/network/${networkId}`); + + return data; + } catch (e) { + throw new Error(`[TMDb] Failed to fetch TV network: ${e.message}`); + } + } + + public async getMovieGenres({ + language = 'en', + }: { + language?: string; + } = {}): Promise { + try { + const data = await this.get( + '/genre/movie/list', + { + params: { + language, + }, + }, + 86400 // 24 hours + ); + + const movieGenres = sortBy(data.genres, 'name'); + + return movieGenres; + } catch (e) { + throw new Error(`[TMDb] Failed to fetch movie genres: ${e.message}`); + } + } + + public async getTvGenres({ + language = 'en', + }: { + language?: string; + } = {}): Promise { + try { + const data = await this.get( + '/genre/tv/list', + { + params: { + language, + }, + }, + 86400 // 24 hours + ); + + const tvGenres = sortBy(data.genres, 'name'); + + return tvGenres; + } catch (e) { + 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 1b0da07e..f626a16d 100644 --- a/server/api/themoviedb/interfaces.ts +++ b/server/api/themoviedb/interfaces.ts @@ -109,6 +109,16 @@ export interface TmdbExternalIds { twitter_id?: string; } +export interface TmdbProductionCompany { + id: number; + logo_path?: string; + name: string; + origin_country: string; + homepage?: string; + headquarters?: string; + description?: string; +} + export interface TmdbMovieDetails { id: number; imdb_id?: string; @@ -125,12 +135,7 @@ export interface TmdbMovieDetails { original_title: string; overview?: string; popularity: number; - production_companies: { - id: number; - name: string; - logo_path?: string; - origin_country: string; - }[]; + production_companies: TmdbProductionCompany[]; production_countries: { iso_3166_1: string; name: string; @@ -227,12 +232,7 @@ export interface TmdbTvDetails { last_episode_to_air?: TmdbTvEpisodeResult; name: string; next_episode_to_air?: TmdbTvEpisodeResult; - networks: { - id: number; - name: string; - logo_path: string; - origin_country: string; - }[]; + networks: TmdbNetwork[]; number_of_episodes: number; number_of_seasons: number; origin_country: string[]; @@ -254,6 +254,7 @@ export interface TmdbTvDetails { }[]; seasons: TmdbTvSeasonResult[]; status: string; + tagline?: string; type: string; vote_average: number; vote_count: number; @@ -381,3 +382,21 @@ export interface TmdbLanguage { english_name: string; name: string; } + +export interface TmdbGenresResult { + genres: TmdbGenre[]; +} + +export interface TmdbGenre { + id: number; + name: string; +} + +export interface TmdbNetwork { + id: number; + name: string; + headquarters?: string; + homepage?: string; + logo_path?: string; + origin_country?: string; +} diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 658aee67..78d8ee96 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -144,7 +144,7 @@ export class MediaRequest { * auto approved content */ @AfterUpdate() - public async notifyApprovedOrDeclined(): Promise { + public async notifyApprovedOrDeclined(autoApproved = false): Promise { if ( this.status === MediaRequestStatus.APPROVED || this.status === MediaRequestStatus.DECLINED @@ -171,7 +171,9 @@ export class MediaRequest { const movie = await tmdb.getMovie({ movieId: this.media.tmdbId }); notificationManager.sendNotification( this.status === MediaRequestStatus.APPROVED - ? Notification.MEDIA_APPROVED + ? autoApproved + ? Notification.MEDIA_AUTO_APPROVED + : Notification.MEDIA_APPROVED : Notification.MEDIA_DECLINED, { subject: movie.title, @@ -186,7 +188,9 @@ export class MediaRequest { const tv = await tmdb.getTvShow({ tvId: this.media.tmdbId }); notificationManager.sendNotification( this.status === MediaRequestStatus.APPROVED - ? Notification.MEDIA_APPROVED + ? autoApproved + ? Notification.MEDIA_AUTO_APPROVED + : Notification.MEDIA_APPROVED : Notification.MEDIA_DECLINED, { subject: tv.name, @@ -211,13 +215,8 @@ export class MediaRequest { @AfterInsert() public async autoapprovalNotification(): Promise { - const settings = getSettings().notifications; - - if ( - settings.autoapprovalEnabled && - this.status === MediaRequestStatus.APPROVED - ) { - this.notifyApprovedOrDeclined(); + if (this.status === MediaRequestStatus.APPROVED) { + this.notifyApprovedOrDeclined(true); } } diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index 163de134..8e60865a 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -26,9 +26,18 @@ export class UserSettings { @Column({ nullable: true }) public discordId?: string; + @Column({ nullable: true }) + public telegramChatId?: string; + + @Column({ nullable: true }) + public telegramSendSilently?: boolean; + @Column({ nullable: true }) public region?: string; @Column({ nullable: true }) public originalLanguage?: string; + + @Column({ nullable: true }) + public pgpKey?: string; } diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index 023b7631..91653991 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -6,5 +6,9 @@ export interface UserSettingsGeneralResponse { export interface UserSettingsNotificationsResponse { enableNotifications: boolean; + telegramBotUsername?: string; discordId?: string; + telegramChatId?: string; + telegramSendSilently?: boolean; + pgpKey?: string; } diff --git a/server/job/plexsync/index.ts b/server/job/plexsync/index.ts deleted file mode 100644 index f4a57c62..00000000 --- a/server/job/plexsync/index.ts +++ /dev/null @@ -1,926 +0,0 @@ -import { getRepository } from 'typeorm'; -import { User } from '../../entity/User'; -import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../api/plexapi'; -import TheMovieDb from '../../api/themoviedb'; -import { - TmdbMovieDetails, - TmdbTvDetails, -} from '../../api/themoviedb/interfaces'; -import Media from '../../entity/Media'; -import { MediaStatus, MediaType } from '../../constants/media'; -import logger from '../../logger'; -import { getSettings, Library } from '../../lib/settings'; -import Season from '../../entity/Season'; -import { uniqWith } from 'lodash'; -import { v4 as uuid } from 'uuid'; -import animeList from '../../api/animelist'; -import AsyncLock from '../../utils/asyncLock'; - -const BUNDLE_SIZE = 20; -const UPDATE_RATE = 4 * 1000; - -const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/); -const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/); -const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/); -const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/); -const plexRegex = new RegExp(/plex:\/\//); -// Hama agent uses ASS naming, see details here: -// https://github.com/ZeroQI/Absolute-Series-Scanner/blob/master/README.md#forcing-the-movieseries-id -const hamaTvdbRegex = new RegExp(/hama:\/\/tvdb[0-9]?-([0-9]+)/); -const hamaAnidbRegex = new RegExp(/hama:\/\/anidb[0-9]?-([0-9]+)/); -const HAMA_AGENT = 'com.plexapp.agents.hama'; - -interface SyncStatus { - running: boolean; - progress: number; - total: number; - currentLibrary: Library; - libraries: Library[]; -} - -class JobPlexSync { - private sessionId: string; - private tmdb: TheMovieDb; - private plexClient: PlexAPI; - private items: PlexLibraryItem[] = []; - private progress = 0; - private libraries: Library[]; - private currentLibrary: Library; - private running = false; - private isRecentOnly = false; - private enable4kMovie = false; - private enable4kShow = false; - private asyncLock = new AsyncLock(); - - constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) { - this.tmdb = new TheMovieDb(); - this.isRecentOnly = isRecentOnly ?? false; - } - - private async getExisting(tmdbId: number, mediaType: MediaType) { - const mediaRepository = getRepository(Media); - - const existing = await mediaRepository.findOne({ - where: { tmdbId: tmdbId, mediaType }, - }); - - return existing; - } - - private async processMovie(plexitem: PlexLibraryItem) { - const mediaRepository = getRepository(Media); - - try { - if (plexitem.guid.match(plexRegex)) { - const metadata = await this.plexClient.getMetadata(plexitem.ratingKey); - const newMedia = new Media(); - - if (!metadata.Guid) { - logger.debug('No Guid metadata for this title. Skipping', { - label: 'Plex Sync', - ratingKey: plexitem.ratingKey, - }); - return; - } - - metadata.Guid.forEach((ref) => { - if (ref.id.match(imdbRegex)) { - newMedia.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined; - } else if (ref.id.match(tmdbRegex)) { - const tmdbMatch = ref.id.match(tmdbRegex)?.[1]; - newMedia.tmdbId = Number(tmdbMatch); - } - }); - if (newMedia.imdbId && !newMedia.tmdbId) { - const tmdbMovie = await this.tmdb.getMovieByImdbId({ - imdbId: newMedia.imdbId, - }); - newMedia.tmdbId = tmdbMovie.id; - } - if (!newMedia.tmdbId) { - throw new Error('Unable to find TMDb ID'); - } - - const has4k = metadata.Media.some( - (media) => media.videoResolution === '4k' - ); - const hasOtherResolution = metadata.Media.some( - (media) => media.videoResolution !== '4k' - ); - - await this.asyncLock.dispatch(newMedia.tmdbId, async () => { - const existing = await this.getExisting( - newMedia.tmdbId, - MediaType.MOVIE - ); - - if (existing) { - let changedExisting = false; - - if ( - (hasOtherResolution || (!this.enable4kMovie && has4k)) && - existing.status !== MediaStatus.AVAILABLE - ) { - existing.status = MediaStatus.AVAILABLE; - existing.mediaAddedAt = new Date(plexitem.addedAt * 1000); - changedExisting = true; - } - - if ( - has4k && - this.enable4kMovie && - existing.status4k !== MediaStatus.AVAILABLE - ) { - existing.status4k = MediaStatus.AVAILABLE; - changedExisting = true; - } - - if (!existing.mediaAddedAt && !changedExisting) { - existing.mediaAddedAt = new Date(plexitem.addedAt * 1000); - changedExisting = true; - } - - if ( - (hasOtherResolution || (has4k && !this.enable4kMovie)) && - existing.ratingKey !== plexitem.ratingKey - ) { - existing.ratingKey = plexitem.ratingKey; - changedExisting = true; - } - - if ( - has4k && - this.enable4kMovie && - existing.ratingKey4k !== plexitem.ratingKey - ) { - existing.ratingKey4k = plexitem.ratingKey; - changedExisting = true; - } - - if (changedExisting) { - await mediaRepository.save(existing); - this.log( - `Request for ${metadata.title} exists. New media types set to AVAILABLE`, - 'info' - ); - } else { - this.log( - `Title already exists and no new media types found ${metadata.title}` - ); - } - } else { - newMedia.status = - hasOtherResolution || (!this.enable4kMovie && has4k) - ? MediaStatus.AVAILABLE - : MediaStatus.UNKNOWN; - newMedia.status4k = - has4k && this.enable4kMovie - ? MediaStatus.AVAILABLE - : MediaStatus.UNKNOWN; - newMedia.mediaType = MediaType.MOVIE; - newMedia.mediaAddedAt = new Date(plexitem.addedAt * 1000); - newMedia.ratingKey = - hasOtherResolution || (!this.enable4kMovie && has4k) - ? plexitem.ratingKey - : undefined; - newMedia.ratingKey4k = - has4k && this.enable4kMovie ? plexitem.ratingKey : undefined; - await mediaRepository.save(newMedia); - this.log(`Saved ${plexitem.title}`); - } - }); - } else { - let tmdbMovieId: number | undefined; - let tmdbMovie: TmdbMovieDetails | undefined; - - const imdbMatch = plexitem.guid.match(imdbRegex); - const tmdbMatch = plexitem.guid.match(tmdbShowRegex); - - if (imdbMatch) { - tmdbMovie = await this.tmdb.getMovieByImdbId({ - imdbId: imdbMatch[1], - }); - tmdbMovieId = tmdbMovie.id; - } else if (tmdbMatch) { - tmdbMovieId = Number(tmdbMatch[1]); - } - - if (!tmdbMovieId) { - throw new Error('Unable to find TMDb ID'); - } - - await this.processMovieWithId(plexitem, tmdbMovie, tmdbMovieId); - } - } catch (e) { - this.log( - `Failed to process Plex item. ratingKey: ${plexitem.ratingKey}`, - 'error', - { - errorMessage: e.message, - plexitem, - } - ); - } - } - - private async processMovieWithId( - plexitem: PlexLibraryItem, - tmdbMovie: TmdbMovieDetails | undefined, - tmdbMovieId: number - ) { - const mediaRepository = getRepository(Media); - - await this.asyncLock.dispatch(tmdbMovieId, async () => { - const metadata = await this.plexClient.getMetadata(plexitem.ratingKey); - const existing = await this.getExisting(tmdbMovieId, MediaType.MOVIE); - - const has4k = metadata.Media.some( - (media) => media.videoResolution === '4k' - ); - const hasOtherResolution = metadata.Media.some( - (media) => media.videoResolution !== '4k' - ); - - if (existing) { - let changedExisting = false; - - if ( - (hasOtherResolution || (!this.enable4kMovie && has4k)) && - existing.status !== MediaStatus.AVAILABLE - ) { - existing.status = MediaStatus.AVAILABLE; - existing.mediaAddedAt = new Date(plexitem.addedAt * 1000); - changedExisting = true; - } - - if ( - has4k && - this.enable4kMovie && - existing.status4k !== MediaStatus.AVAILABLE - ) { - existing.status4k = MediaStatus.AVAILABLE; - changedExisting = true; - } - - if (!existing.mediaAddedAt && !changedExisting) { - existing.mediaAddedAt = new Date(plexitem.addedAt * 1000); - changedExisting = true; - } - - if ( - (hasOtherResolution || (has4k && !this.enable4kMovie)) && - existing.ratingKey !== plexitem.ratingKey - ) { - existing.ratingKey = plexitem.ratingKey; - changedExisting = true; - } - - if ( - has4k && - this.enable4kMovie && - existing.ratingKey4k !== plexitem.ratingKey - ) { - existing.ratingKey4k = plexitem.ratingKey; - changedExisting = true; - } - - if (changedExisting) { - await mediaRepository.save(existing); - this.log( - `Request for ${metadata.title} exists. New media types set to AVAILABLE`, - 'info' - ); - } else { - this.log( - `Title already exists and no new media types found ${metadata.title}` - ); - } - } else { - // If we have a tmdb movie guid but it didn't already exist, only then - // do we request the movie from tmdb (to reduce api requests) - if (!tmdbMovie) { - tmdbMovie = await this.tmdb.getMovie({ movieId: tmdbMovieId }); - } - const newMedia = new Media(); - newMedia.imdbId = tmdbMovie.external_ids.imdb_id; - newMedia.tmdbId = tmdbMovie.id; - newMedia.mediaAddedAt = new Date(plexitem.addedAt * 1000); - newMedia.status = - hasOtherResolution || (!this.enable4kMovie && has4k) - ? MediaStatus.AVAILABLE - : MediaStatus.UNKNOWN; - newMedia.status4k = - has4k && this.enable4kMovie - ? MediaStatus.AVAILABLE - : MediaStatus.UNKNOWN; - newMedia.mediaType = MediaType.MOVIE; - newMedia.ratingKey = - hasOtherResolution || (!this.enable4kMovie && has4k) - ? plexitem.ratingKey - : undefined; - newMedia.ratingKey4k = - has4k && this.enable4kMovie ? plexitem.ratingKey : undefined; - await mediaRepository.save(newMedia); - this.log(`Saved ${tmdbMovie.title}`); - } - }); - } - - // this adds all movie episodes from specials season for Hama agent - private async processHamaSpecials(metadata: PlexMetadata, tvdbId: number) { - const specials = metadata.Children?.Metadata.find( - (md) => Number(md.index) === 0 - ); - if (specials) { - const episodes = await this.plexClient.getChildrenMetadata( - specials.ratingKey - ); - if (episodes) { - for (const episode of episodes) { - const special = animeList.getSpecialEpisode(tvdbId, episode.index); - if (special) { - if (special.tmdbId) { - await this.processMovieWithId(episode, undefined, special.tmdbId); - } else if (special.imdbId) { - const tmdbMovie = await this.tmdb.getMovieByImdbId({ - imdbId: special.imdbId, - }); - await this.processMovieWithId(episode, tmdbMovie, tmdbMovie.id); - } - } - } - } - } - } - - // movies with hama agent actually are tv shows with at least one episode in it - // try to get first episode of any season - cannot hardcode season or episode number - // because sometimes user can have it in other season/ep than s01e01 - private async processHamaMovie( - metadata: PlexMetadata, - tmdbMovie: TmdbMovieDetails | undefined, - tmdbMovieId: number - ) { - const season = metadata.Children?.Metadata[0]; - if (season) { - const episodes = await this.plexClient.getChildrenMetadata( - season.ratingKey - ); - if (episodes) { - await this.processMovieWithId(episodes[0], tmdbMovie, tmdbMovieId); - } - } - } - - private async processShow(plexitem: PlexLibraryItem) { - const mediaRepository = getRepository(Media); - - let tvShow: TmdbTvDetails | null = null; - - try { - const ratingKey = - plexitem.grandparentRatingKey ?? - plexitem.parentRatingKey ?? - plexitem.ratingKey; - const metadata = await this.plexClient.getMetadata(ratingKey, { - includeChildren: true, - }); - - if (metadata.guid.match(tvdbRegex)) { - const matchedtvdb = metadata.guid.match(tvdbRegex); - - // If we can find a tvdb Id, use it to get the full tmdb show details - if (matchedtvdb?.[1]) { - tvShow = await this.tmdb.getShowByTvdbId({ - tvdbId: Number(matchedtvdb[1]), - }); - } - } else if (metadata.guid.match(tmdbShowRegex)) { - const matchedtmdb = metadata.guid.match(tmdbShowRegex); - - if (matchedtmdb?.[1]) { - tvShow = await this.tmdb.getTvShow({ tvId: Number(matchedtmdb[1]) }); - } - } else if (metadata.guid.match(hamaTvdbRegex)) { - const matched = metadata.guid.match(hamaTvdbRegex); - const tvdbId = matched?.[1]; - - if (tvdbId) { - tvShow = await this.tmdb.getShowByTvdbId({ tvdbId: Number(tvdbId) }); - if (animeList.isLoaded()) { - await this.processHamaSpecials(metadata, Number(tvdbId)); - } else { - this.log( - `Hama ID ${plexitem.guid} detected, but library agent is not set to Hama`, - 'warn' - ); - } - } - } else if (metadata.guid.match(hamaAnidbRegex)) { - const matched = metadata.guid.match(hamaAnidbRegex); - - if (!animeList.isLoaded()) { - this.log( - `Hama ID ${plexitem.guid} detected, but library agent is not set to Hama`, - 'warn' - ); - } else if (matched?.[1]) { - const anidbId = Number(matched[1]); - const result = animeList.getFromAnidbId(anidbId); - - // first try to lookup tvshow by tvdbid - if (result?.tvdbId) { - const extResponse = await this.tmdb.getByExternalId({ - externalId: result.tvdbId, - type: 'tvdb', - }); - if (extResponse.tv_results[0]) { - tvShow = await this.tmdb.getTvShow({ - tvId: extResponse.tv_results[0].id, - }); - } else { - this.log( - `Missing TVDB ${result.tvdbId} entry in TMDB for AniDB ${anidbId}` - ); - } - await this.processHamaSpecials(metadata, result.tvdbId); - } - - if (!tvShow) { - // if lookup of tvshow above failed, then try movie with tmdbid/imdbid - // note - some tv shows have imdbid set too, that's why this need to go second - if (result?.tmdbId) { - return await this.processHamaMovie( - metadata, - undefined, - result.tmdbId - ); - } else if (result?.imdbId) { - const tmdbMovie = await this.tmdb.getMovieByImdbId({ - imdbId: result.imdbId, - }); - return await this.processHamaMovie( - metadata, - tmdbMovie, - tmdbMovie.id - ); - } - } - } - } - - if (tvShow) { - await this.asyncLock.dispatch(tvShow.id, async () => { - if (!tvShow) { - // this will never execute, but typescript thinks somebody could reset tvShow from - // outer scope back to null before this async gets called - return; - } - - // Lets get the available seasons from Plex - const seasons = tvShow.seasons; - const media = await this.getExisting(tvShow.id, MediaType.TV); - - const newSeasons: Season[] = []; - - const currentStandardSeasonAvailable = ( - media?.seasons.filter( - (season) => season.status === MediaStatus.AVAILABLE - ) ?? [] - ).length; - const current4kSeasonAvailable = ( - media?.seasons.filter( - (season) => season.status4k === MediaStatus.AVAILABLE - ) ?? [] - ).length; - - for (const season of seasons) { - const matchedPlexSeason = metadata.Children?.Metadata.find( - (md) => Number(md.index) === season.season_number - ); - - const existingSeason = media?.seasons.find( - (es) => es.seasonNumber === season.season_number - ); - - // Check if we found the matching season and it has all the available episodes - if (matchedPlexSeason) { - // If we have a matched Plex season, get its children metadata so we can check details - const episodes = await this.plexClient.getChildrenMetadata( - matchedPlexSeason.ratingKey - ); - // Total episodes that are in standard definition (not 4k) - const totalStandard = episodes.filter((episode) => - !this.enable4kShow - ? true - : episode.Media.some( - (media) => media.videoResolution !== '4k' - ) - ).length; - - // Total episodes that are in 4k - const total4k = episodes.filter((episode) => - episode.Media.some((media) => media.videoResolution === '4k') - ).length; - - if ( - media && - (totalStandard > 0 || (total4k > 0 && !this.enable4kShow)) && - media.ratingKey !== ratingKey - ) { - media.ratingKey = ratingKey; - } - - if ( - media && - total4k > 0 && - this.enable4kShow && - media.ratingKey4k !== ratingKey - ) { - media.ratingKey4k = ratingKey; - } - - if (existingSeason) { - // These ternary statements look super confusing, but they are simply - // setting the status to AVAILABLE if all of a type is there, partially if some, - // and then not modifying the status if there are 0 items. - // If the season was already available, we don't modify it as well. - existingSeason.status = - totalStandard === season.episode_count || - existingSeason.status === MediaStatus.AVAILABLE - ? MediaStatus.AVAILABLE - : totalStandard > 0 - ? MediaStatus.PARTIALLY_AVAILABLE - : existingSeason.status; - existingSeason.status4k = - (this.enable4kShow && total4k === season.episode_count) || - existingSeason.status4k === MediaStatus.AVAILABLE - ? MediaStatus.AVAILABLE - : this.enable4kShow && total4k > 0 - ? MediaStatus.PARTIALLY_AVAILABLE - : existingSeason.status4k; - } else { - newSeasons.push( - new Season({ - seasonNumber: season.season_number, - // This ternary is the same as the ones above, but it just falls back to "UNKNOWN" - // if we dont have any items for the season - status: - totalStandard === season.episode_count - ? MediaStatus.AVAILABLE - : totalStandard > 0 - ? MediaStatus.PARTIALLY_AVAILABLE - : MediaStatus.UNKNOWN, - status4k: - this.enable4kShow && total4k === season.episode_count - ? MediaStatus.AVAILABLE - : this.enable4kShow && total4k > 0 - ? MediaStatus.PARTIALLY_AVAILABLE - : MediaStatus.UNKNOWN, - }) - ); - } - } - } - - // Remove extras season. We dont count it for determining availability - const filteredSeasons = tvShow.seasons.filter( - (season) => season.season_number !== 0 - ); - - const isAllStandardSeasons = - newSeasons.filter( - (season) => season.status === MediaStatus.AVAILABLE - ).length + - (media?.seasons.filter( - (season) => season.status === MediaStatus.AVAILABLE - ).length ?? 0) >= - filteredSeasons.length; - - const isAll4kSeasons = - newSeasons.filter( - (season) => season.status4k === MediaStatus.AVAILABLE - ).length + - (media?.seasons.filter( - (season) => season.status4k === MediaStatus.AVAILABLE - ).length ?? 0) >= - filteredSeasons.length; - - if (media) { - // Update existing - media.seasons = [...media.seasons, ...newSeasons]; - - const newStandardSeasonAvailable = ( - media.seasons.filter( - (season) => season.status === MediaStatus.AVAILABLE - ) ?? [] - ).length; - - const new4kSeasonAvailable = ( - media.seasons.filter( - (season) => season.status4k === MediaStatus.AVAILABLE - ) ?? [] - ).length; - - // If at least one new season has become available, update - // the lastSeasonChange field so we can trigger notifications - if (newStandardSeasonAvailable > currentStandardSeasonAvailable) { - this.log( - `Detected ${ - newStandardSeasonAvailable - currentStandardSeasonAvailable - } new standard season(s) for ${tvShow.name}`, - 'debug' - ); - media.lastSeasonChange = new Date(); - media.mediaAddedAt = new Date(plexitem.addedAt * 1000); - } - - if (new4kSeasonAvailable > current4kSeasonAvailable) { - this.log( - `Detected ${ - new4kSeasonAvailable - current4kSeasonAvailable - } new 4K season(s) for ${tvShow.name}`, - 'debug' - ); - media.lastSeasonChange = new Date(); - } - - if (!media.mediaAddedAt) { - media.mediaAddedAt = new Date(plexitem.addedAt * 1000); - } - - // If the show is already available, and there are no new seasons, dont adjust - // the status - const shouldStayAvailable = - media.status === MediaStatus.AVAILABLE && - newSeasons.filter( - (season) => season.status !== MediaStatus.UNKNOWN - ).length === 0; - const shouldStayAvailable4k = - media.status4k === MediaStatus.AVAILABLE && - newSeasons.filter( - (season) => season.status4k !== MediaStatus.UNKNOWN - ).length === 0; - - media.status = - isAllStandardSeasons || shouldStayAvailable - ? MediaStatus.AVAILABLE - : media.seasons.some( - (season) => - season.status === MediaStatus.PARTIALLY_AVAILABLE || - season.status === MediaStatus.AVAILABLE - ) - ? MediaStatus.PARTIALLY_AVAILABLE - : MediaStatus.UNKNOWN; - media.status4k = - (isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow - ? MediaStatus.AVAILABLE - : this.enable4kShow && - media.seasons.some( - (season) => - season.status4k === MediaStatus.PARTIALLY_AVAILABLE || - season.status4k === MediaStatus.AVAILABLE - ) - ? MediaStatus.PARTIALLY_AVAILABLE - : MediaStatus.UNKNOWN; - await mediaRepository.save(media); - this.log(`Updating existing title: ${tvShow.name}`); - } else { - const newMedia = new Media({ - mediaType: MediaType.TV, - seasons: newSeasons, - tmdbId: tvShow.id, - tvdbId: tvShow.external_ids.tvdb_id, - mediaAddedAt: new Date(plexitem.addedAt * 1000), - status: isAllStandardSeasons - ? MediaStatus.AVAILABLE - : newSeasons.some( - (season) => - season.status === MediaStatus.PARTIALLY_AVAILABLE || - season.status === MediaStatus.AVAILABLE - ) - ? MediaStatus.PARTIALLY_AVAILABLE - : MediaStatus.UNKNOWN, - status4k: - isAll4kSeasons && this.enable4kShow - ? MediaStatus.AVAILABLE - : this.enable4kShow && - newSeasons.some( - (season) => - season.status4k === MediaStatus.PARTIALLY_AVAILABLE || - season.status4k === MediaStatus.AVAILABLE - ) - ? MediaStatus.PARTIALLY_AVAILABLE - : MediaStatus.UNKNOWN, - }); - await mediaRepository.save(newMedia); - this.log(`Saved ${tvShow.name}`); - } - }); - } else { - this.log(`failed show: ${plexitem.guid}`); - } - } catch (e) { - this.log( - `Failed to process Plex item. ratingKey: ${ - plexitem.grandparentRatingKey ?? - plexitem.parentRatingKey ?? - plexitem.ratingKey - }`, - 'error', - { - errorMessage: e.message, - plexitem, - } - ); - } - } - - private async processItems(slicedItems: PlexLibraryItem[]) { - await Promise.all( - slicedItems.map(async (plexitem) => { - if (plexitem.type === 'movie') { - await this.processMovie(plexitem); - } else if ( - plexitem.type === 'show' || - plexitem.type === 'episode' || - plexitem.type === 'season' - ) { - await this.processShow(plexitem); - } - }) - ); - } - - private async loop({ - start = 0, - end = BUNDLE_SIZE, - sessionId, - }: { - start?: number; - end?: number; - sessionId?: string; - } = {}) { - const slicedItems = this.items.slice(start, end); - - if (!this.running) { - throw new Error('Sync was aborted.'); - } - - if (this.sessionId !== sessionId) { - throw new Error('New session was started. Old session aborted.'); - } - - if (start < this.items.length) { - this.progress = start; - await this.processItems(slicedItems); - - await new Promise((resolve, reject) => - setTimeout(() => { - this.loop({ - start: start + BUNDLE_SIZE, - end: end + BUNDLE_SIZE, - sessionId, - }) - .then(() => resolve()) - .catch((e) => reject(new Error(e.message))); - }, UPDATE_RATE) - ); - } - } - - private log( - message: string, - level: 'info' | 'error' | 'debug' | 'warn' = 'debug', - optional?: Record - ): void { - logger[level](message, { label: 'Plex Sync', ...optional }); - } - - // checks if any of this.libraries has Hama agent set in Plex - private async hasHamaAgent() { - const plexLibraries = await this.plexClient.getLibraries(); - return this.libraries.some((library) => - plexLibraries.some( - (plexLibrary) => - plexLibrary.agent === HAMA_AGENT && library.id === plexLibrary.key - ) - ); - } - - public async run(): Promise { - const settings = getSettings(); - const sessionId = uuid(); - this.sessionId = sessionId; - logger.info('Plex Sync Starting', { sessionId, label: 'Plex Sync' }); - try { - this.running = true; - const userRepository = getRepository(User); - const admin = await userRepository.findOne({ - select: ['id', 'plexToken'], - order: { id: 'ASC' }, - }); - - if (!admin) { - return this.log('No admin configured. Plex sync skipped.', 'warn'); - } - - this.plexClient = new PlexAPI({ plexToken: admin.plexToken }); - - this.libraries = settings.plex.libraries.filter( - (library) => library.enabled - ); - - this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k); - if (this.enable4kMovie) { - this.log( - 'At least one 4K Radarr server was detected. 4K movie detection is now enabled', - 'info' - ); - } - - this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k); - if (this.enable4kShow) { - this.log( - 'At least one 4K Sonarr server was detected. 4K series detection is now enabled', - 'info' - ); - } - - const hasHama = await this.hasHamaAgent(); - if (hasHama) { - await animeList.sync(); - } - - if (this.isRecentOnly) { - for (const library of this.libraries) { - this.currentLibrary = library; - this.log( - `Beginning to process recently added for library: ${library.name}`, - 'info' - ); - const libraryItems = await this.plexClient.getRecentlyAdded( - library.id - ); - - // Bundle items up by rating keys - this.items = uniqWith(libraryItems, (mediaA, mediaB) => { - if (mediaA.grandparentRatingKey && mediaB.grandparentRatingKey) { - return ( - mediaA.grandparentRatingKey === mediaB.grandparentRatingKey - ); - } - - if (mediaA.parentRatingKey && mediaB.parentRatingKey) { - return mediaA.parentRatingKey === mediaB.parentRatingKey; - } - - return mediaA.ratingKey === mediaB.ratingKey; - }); - - await this.loop({ sessionId }); - } - } else { - for (const library of this.libraries) { - this.currentLibrary = library; - this.log(`Beginning to process library: ${library.name}`, 'info'); - this.items = await this.plexClient.getLibraryContents(library.id); - await this.loop({ sessionId }); - } - } - this.log( - this.isRecentOnly - ? 'Recently Added Scan Complete' - : 'Full Scan Complete', - 'info' - ); - } catch (e) { - logger.error('Sync interrupted', { - label: 'Plex Sync', - errorMessage: e.message, - }); - } finally { - // If a new scanning session hasnt started, set running back to false - if (this.sessionId === sessionId) { - this.running = false; - } - } - } - - public status(): SyncStatus { - return { - running: this.running, - progress: this.progress, - total: this.items.length, - currentLibrary: this.currentLibrary, - libraries: this.libraries, - }; - } - - public cancel(): void { - this.running = false; - } -} - -export const jobPlexFullSync = new JobPlexSync(); -export const jobPlexRecentSync = new JobPlexSync({ isRecentOnly: true }); diff --git a/server/job/radarrsync/index.ts b/server/job/radarrsync/index.ts deleted file mode 100644 index 57f88ee0..00000000 --- a/server/job/radarrsync/index.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { uniqWith } from 'lodash'; -import { getRepository } from 'typeorm'; -import { v4 as uuid } from 'uuid'; -import RadarrAPI, { RadarrMovie } from '../../api/radarr'; -import { MediaStatus, MediaType } from '../../constants/media'; -import Media from '../../entity/Media'; -import { getSettings, RadarrSettings } from '../../lib/settings'; -import logger from '../../logger'; - -const BUNDLE_SIZE = 50; -const UPDATE_RATE = 4 * 1000; - -interface SyncStatus { - running: boolean; - progress: number; - total: number; - currentServer: RadarrSettings; - servers: RadarrSettings[]; -} - -class JobRadarrSync { - private running = false; - private progress = 0; - private enable4k = false; - private sessionId: string; - private servers: RadarrSettings[]; - private currentServer: RadarrSettings; - private radarrApi: RadarrAPI; - private items: RadarrMovie[] = []; - - public async run() { - const settings = getSettings(); - const sessionId = uuid(); - this.sessionId = sessionId; - this.log('Radarr sync starting', 'info', { sessionId }); - - try { - this.running = true; - - // Remove any duplicate Radarr servers and assign them to the servers field - this.servers = uniqWith(settings.radarr, (radarrA, radarrB) => { - return ( - radarrA.hostname === radarrB.hostname && - radarrA.port === radarrB.port && - radarrA.baseUrl === radarrB.baseUrl - ); - }); - - this.enable4k = settings.radarr.some((radarr) => radarr.is4k); - if (this.enable4k) { - this.log( - 'At least one 4K Radarr server was detected. 4K movie detection is now enabled.', - 'info' - ); - } - - for (const server of this.servers) { - this.currentServer = server; - if (server.syncEnabled) { - this.log( - `Beginning to process Radarr server: ${server.name}`, - 'info' - ); - - this.radarrApi = new RadarrAPI({ - apiKey: server.apiKey, - url: RadarrAPI.buildRadarrUrl(server, '/api/v3'), - }); - - this.items = await this.radarrApi.getMovies(); - - await this.loop({ sessionId }); - } else { - this.log(`Sync not enabled. Skipping Radarr server: ${server.name}`); - } - } - - this.log('Radarr sync complete', 'info'); - } catch (e) { - this.log('Something went wrong.', 'error', { errorMessage: e.message }); - } finally { - // If a new scanning session hasnt started, set running back to false - if (this.sessionId === sessionId) { - this.running = false; - } - } - } - - public status(): SyncStatus { - return { - running: this.running, - progress: this.progress, - total: this.items.length, - currentServer: this.currentServer, - servers: this.servers, - }; - } - - public cancel(): void { - this.running = false; - } - - private async processRadarrMovie(radarrMovie: RadarrMovie) { - const mediaRepository = getRepository(Media); - const server4k = this.enable4k && this.currentServer.is4k; - - const media = await mediaRepository.findOne({ - where: { tmdbId: radarrMovie.tmdbId }, - }); - - if (media) { - let isChanged = false; - if (media.status === MediaStatus.AVAILABLE) { - this.log(`Movie already available: ${radarrMovie.title}`); - } else { - media[server4k ? 'status4k' : 'status'] = radarrMovie.downloaded - ? MediaStatus.AVAILABLE - : MediaStatus.PROCESSING; - this.log( - `Updated existing ${server4k ? '4K ' : ''}movie ${ - radarrMovie.title - } to status ${MediaStatus[media[server4k ? 'status4k' : 'status']]}` - ); - isChanged = true; - } - - if ( - media[server4k ? 'serviceId4k' : 'serviceId'] !== this.currentServer.id - ) { - media[server4k ? 'serviceId4k' : 'serviceId'] = this.currentServer.id; - this.log(`Updated service ID for media entity: ${radarrMovie.title}`); - isChanged = true; - } - - if ( - media[server4k ? 'externalServiceId4k' : 'externalServiceId'] !== - radarrMovie.id - ) { - media[server4k ? 'externalServiceId4k' : 'externalServiceId'] = - radarrMovie.id; - this.log( - `Updated external service ID for media entity: ${radarrMovie.title}` - ); - isChanged = true; - } - - if ( - media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !== - radarrMovie.titleSlug - ) { - media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = - radarrMovie.titleSlug; - this.log( - `Updated external service slug for media entity: ${radarrMovie.title}` - ); - isChanged = true; - } - - if (isChanged) { - await mediaRepository.save(media); - } - } else { - const newMedia = new Media({ - tmdbId: radarrMovie.tmdbId, - imdbId: radarrMovie.imdbId, - mediaType: MediaType.MOVIE, - serviceId: !server4k ? this.currentServer.id : undefined, - serviceId4k: server4k ? this.currentServer.id : undefined, - externalServiceId: !server4k ? radarrMovie.id : undefined, - externalServiceId4k: server4k ? radarrMovie.id : undefined, - status: - !server4k && radarrMovie.downloaded - ? MediaStatus.AVAILABLE - : !server4k - ? MediaStatus.PROCESSING - : MediaStatus.UNKNOWN, - status4k: - server4k && radarrMovie.downloaded - ? MediaStatus.AVAILABLE - : server4k - ? MediaStatus.PROCESSING - : MediaStatus.UNKNOWN, - }); - - this.log( - `Added media for movie ${radarrMovie.title} and set status to ${ - MediaStatus[newMedia[server4k ? 'status4k' : 'status']] - }` - ); - await mediaRepository.save(newMedia); - } - } - - private async processItems(items: RadarrMovie[]) { - await Promise.all( - items.map(async (radarrMovie) => { - await this.processRadarrMovie(radarrMovie); - }) - ); - } - - private async loop({ - start = 0, - end = BUNDLE_SIZE, - sessionId, - }: { - start?: number; - end?: number; - sessionId?: string; - } = {}) { - const slicedItems = this.items.slice(start, end); - - if (!this.running) { - throw new Error('Sync was aborted.'); - } - - if (this.sessionId !== sessionId) { - throw new Error('New session was started. Old session aborted.'); - } - - if (start < this.items.length) { - this.progress = start; - await this.processItems(slicedItems); - - await new Promise((resolve, reject) => - setTimeout(() => { - this.loop({ - start: start + BUNDLE_SIZE, - end: end + BUNDLE_SIZE, - sessionId, - }) - .then(() => resolve()) - .catch((e) => reject(new Error(e.message))); - }, UPDATE_RATE) - ); - } - } - - private log( - message: string, - level: 'info' | 'error' | 'debug' | 'warn' = 'debug', - optional?: Record - ): void { - logger[level](message, { label: 'Radarr Sync', ...optional }); - } -} - -export const jobRadarrSync = new JobRadarrSync(); diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 342f54a1..1e3665b8 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -1,9 +1,9 @@ import schedule from 'node-schedule'; -import { jobPlexFullSync, jobPlexRecentSync } from './plexsync'; import logger from '../logger'; -import { jobRadarrSync } from './radarrsync'; -import { jobSonarrSync } from './sonarrsync'; import downloadTracker from '../lib/downloadtracker'; +import { plexFullScanner, plexRecentScanner } from '../lib/scanners/plex'; +import { radarrScanner } from '../lib/scanners/radarr'; +import { sonarrScanner } from '../lib/scanners/sonarr'; interface ScheduledJob { id: string; @@ -17,58 +17,60 @@ interface ScheduledJob { export const scheduledJobs: ScheduledJob[] = []; export const startJobs = (): void => { - // Run recently added plex sync every 5 minutes + // Run recently added plex scan every 5 minutes scheduledJobs.push({ - id: 'plex-recently-added-sync', - name: 'Plex Recently Added Sync', + id: 'plex-recently-added-scan', + name: 'Plex Recently Added Scan', type: 'process', job: schedule.scheduleJob('0 */5 * * * *', () => { - logger.info('Starting scheduled job: Plex Recently Added Sync', { + logger.info('Starting scheduled job: Plex Recently Added Scan', { label: 'Jobs', }); - jobPlexRecentSync.run(); + plexRecentScanner.run(); }), - running: () => jobPlexRecentSync.status().running, - cancelFn: () => jobPlexRecentSync.cancel(), + running: () => plexRecentScanner.status().running, + cancelFn: () => plexRecentScanner.cancel(), }); - // Run full plex sync every 24 hours + // Run full plex scan every 24 hours scheduledJobs.push({ - id: 'plex-full-sync', - name: 'Plex Full Library Sync', + id: 'plex-full-scan', + name: 'Plex Full Library Scan', type: 'process', job: schedule.scheduleJob('0 0 3 * * *', () => { - logger.info('Starting scheduled job: Plex Full Sync', { label: 'Jobs' }); - jobPlexFullSync.run(); + logger.info('Starting scheduled job: Plex Full Library Scan', { + label: 'Jobs', + }); + plexFullScanner.run(); }), - running: () => jobPlexFullSync.status().running, - cancelFn: () => jobPlexFullSync.cancel(), + running: () => plexFullScanner.status().running, + cancelFn: () => plexFullScanner.cancel(), }); - // Run full radarr sync every 24 hours + // Run full radarr scan every 24 hours scheduledJobs.push({ - id: 'radarr-sync', - name: 'Radarr Sync', + id: 'radarr-scan', + name: 'Radarr Scan', type: 'process', job: schedule.scheduleJob('0 0 4 * * *', () => { - logger.info('Starting scheduled job: Radarr Sync', { label: 'Jobs' }); - jobRadarrSync.run(); + logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' }); + radarrScanner.run(); }), - running: () => jobRadarrSync.status().running, - cancelFn: () => jobRadarrSync.cancel(), + running: () => radarrScanner.status().running, + cancelFn: () => radarrScanner.cancel(), }); - // Run full sonarr sync every 24 hours + // Run full sonarr scan every 24 hours scheduledJobs.push({ - id: 'sonarr-sync', - name: 'Sonarr Sync', + id: 'sonarr-scan', + name: 'Sonarr Scan', type: 'process', job: schedule.scheduleJob('0 30 4 * * *', () => { - logger.info('Starting scheduled job: Sonarr Sync', { label: 'Jobs' }); - jobSonarrSync.run(); + logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' }); + sonarrScanner.run(); }), - running: () => jobSonarrSync.status().running, - cancelFn: () => jobSonarrSync.cancel(), + running: () => sonarrScanner.status().running, + cancelFn: () => sonarrScanner.cancel(), }); // Run download sync diff --git a/server/job/sonarrsync/index.ts b/server/job/sonarrsync/index.ts deleted file mode 100644 index 3685af48..00000000 --- a/server/job/sonarrsync/index.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { uniqWith } from 'lodash'; -import { getRepository } from 'typeorm'; -import { v4 as uuid } from 'uuid'; -import SonarrAPI, { SonarrSeries } from '../../api/sonarr'; -import TheMovieDb from '../../api/themoviedb'; -import { TmdbTvDetails } from '../../api/themoviedb/interfaces'; -import { MediaStatus, MediaType } from '../../constants/media'; -import Media from '../../entity/Media'; -import Season from '../../entity/Season'; -import { getSettings, SonarrSettings } from '../../lib/settings'; -import logger from '../../logger'; - -const BUNDLE_SIZE = 50; -const UPDATE_RATE = 4 * 1000; - -interface SyncStatus { - running: boolean; - progress: number; - total: number; - currentServer: SonarrSettings; - servers: SonarrSettings[]; -} - -class JobSonarrSync { - private running = false; - private progress = 0; - private enable4k = false; - private sessionId: string; - private servers: SonarrSettings[]; - private currentServer: SonarrSettings; - private sonarrApi: SonarrAPI; - private items: SonarrSeries[] = []; - - public async run() { - const settings = getSettings(); - const sessionId = uuid(); - this.sessionId = sessionId; - this.log('Sonarr sync starting', 'info', { sessionId }); - - try { - this.running = true; - - // Remove any duplicate Sonarr servers and assign them to the servers field - this.servers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => { - return ( - sonarrA.hostname === sonarrB.hostname && - sonarrA.port === sonarrB.port && - sonarrA.baseUrl === sonarrB.baseUrl - ); - }); - - this.enable4k = settings.sonarr.some((sonarr) => sonarr.is4k); - if (this.enable4k) { - this.log( - 'At least one 4K Sonarr server was detected. 4K movie detection is now enabled.', - 'info' - ); - } - - for (const server of this.servers) { - this.currentServer = server; - if (server.syncEnabled) { - this.log( - `Beginning to process Sonarr server: ${server.name}`, - 'info' - ); - - this.sonarrApi = new SonarrAPI({ - apiKey: server.apiKey, - url: SonarrAPI.buildSonarrUrl(server, '/api/v3'), - }); - - this.items = await this.sonarrApi.getSeries(); - - await this.loop({ sessionId }); - } else { - this.log(`Sync not enabled. Skipping Sonarr server: ${server.name}`); - } - } - - this.log('Sonarr sync complete', 'info'); - } catch (e) { - this.log('Something went wrong.', 'error', { errorMessage: e.message }); - } finally { - // If a new scanning session hasnt started, set running back to false - if (this.sessionId === sessionId) { - this.running = false; - } - } - } - - public status(): SyncStatus { - return { - running: this.running, - progress: this.progress, - total: this.items.length, - currentServer: this.currentServer, - servers: this.servers, - }; - } - - public cancel(): void { - this.running = false; - } - - private async processSonarrSeries(sonarrSeries: SonarrSeries) { - const mediaRepository = getRepository(Media); - const server4k = this.enable4k && this.currentServer.is4k; - - const media = await mediaRepository.findOne({ - where: { tvdbId: sonarrSeries.tvdbId }, - }); - - const currentSeasonsAvailable = (media?.seasons ?? []).filter( - (season) => - season[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE - ).length; - - const newSeasons: Season[] = []; - - for (const season of sonarrSeries.seasons) { - const existingSeason = media?.seasons.find( - (es) => es.seasonNumber === season.seasonNumber - ); - - // We are already tracking this season so we can work on it directly - if (existingSeason) { - if ( - existingSeason[server4k ? 'status4k' : 'status'] !== - MediaStatus.AVAILABLE && - season.statistics - ) { - existingSeason[server4k ? 'status4k' : 'status'] = - season.statistics.episodeFileCount === - season.statistics.totalEpisodeCount - ? MediaStatus.AVAILABLE - : season.statistics.episodeFileCount > 0 - ? MediaStatus.PARTIALLY_AVAILABLE - : season.monitored - ? MediaStatus.PROCESSING - : existingSeason[server4k ? 'status4k' : 'status']; - } - } else { - if (season.statistics && season.seasonNumber !== 0) { - const allEpisodes = - season.statistics.episodeFileCount === - season.statistics.totalEpisodeCount; - newSeasons.push( - new Season({ - seasonNumber: season.seasonNumber, - status: - !server4k && allEpisodes - ? MediaStatus.AVAILABLE - : !server4k && season.statistics.episodeFileCount > 0 - ? MediaStatus.PARTIALLY_AVAILABLE - : !server4k && season.monitored - ? MediaStatus.PROCESSING - : MediaStatus.UNKNOWN, - status4k: - server4k && allEpisodes - ? MediaStatus.AVAILABLE - : server4k && season.statistics.episodeFileCount > 0 - ? MediaStatus.PARTIALLY_AVAILABLE - : !server4k && season.monitored - ? MediaStatus.PROCESSING - : MediaStatus.UNKNOWN, - }) - ); - } - } - } - - const filteredSeasons = sonarrSeries.seasons.filter( - (s) => s.seasonNumber !== 0 - ); - - const isAllSeasons = - (media?.seasons ?? []).filter( - (s) => s[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE - ).length + - newSeasons.filter( - (s) => s[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE - ).length >= - filteredSeasons.length && filteredSeasons.length > 0; - - if (media) { - media.seasons = [...media.seasons, ...newSeasons]; - - const newSeasonsAvailable = (media?.seasons ?? []).filter( - (season) => - season[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE - ).length; - - if (newSeasonsAvailable > currentSeasonsAvailable) { - this.log( - `Detected ${newSeasonsAvailable - currentSeasonsAvailable} new ${ - server4k ? '4K ' : '' - }season(s) for ${sonarrSeries.title}`, - 'debug' - ); - media.lastSeasonChange = new Date(); - } - - if ( - media[server4k ? 'serviceId4k' : 'serviceId'] !== this.currentServer.id - ) { - media[server4k ? 'serviceId4k' : 'serviceId'] = this.currentServer.id; - this.log(`Updated service ID for media entity: ${sonarrSeries.title}`); - } - - if ( - media[server4k ? 'externalServiceId4k' : 'externalServiceId'] !== - sonarrSeries.id - ) { - media[server4k ? 'externalServiceId4k' : 'externalServiceId'] = - sonarrSeries.id; - this.log( - `Updated external service ID for media entity: ${sonarrSeries.title}` - ); - } - - if ( - media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !== - sonarrSeries.titleSlug - ) { - media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = - sonarrSeries.titleSlug; - this.log( - `Updated external service slug for media entity: ${sonarrSeries.title}` - ); - } - - // If the show is already available, and there are no new seasons, dont adjust - // the status - const shouldStayAvailable = - media.status === MediaStatus.AVAILABLE && - newSeasons.filter( - (season) => - season[server4k ? 'status4k' : 'status'] !== MediaStatus.UNKNOWN - ).length === 0; - - media[server4k ? 'status4k' : 'status'] = - isAllSeasons || shouldStayAvailable - ? MediaStatus.AVAILABLE - : media.seasons.some( - (season) => - season[server4k ? 'status4k' : 'status'] === - MediaStatus.AVAILABLE || - season[server4k ? 'status4k' : 'status'] === - MediaStatus.PARTIALLY_AVAILABLE - ) - ? MediaStatus.PARTIALLY_AVAILABLE - : media.seasons.some( - (season) => - season[server4k ? 'status4k' : 'status'] === - MediaStatus.PROCESSING - ) - ? MediaStatus.PROCESSING - : MediaStatus.UNKNOWN; - - await mediaRepository.save(media); - } else { - const tmdb = new TheMovieDb(); - let tvShow: TmdbTvDetails; - - try { - tvShow = await tmdb.getShowByTvdbId({ - tvdbId: sonarrSeries.tvdbId, - }); - } catch (e) { - this.log( - 'Failed to create new media item during sync. TVDB ID is missing from TMDB?', - 'warn', - { sonarrSeries, errorMessage: e.message } - ); - return; - } - - const newMedia = new Media({ - tmdbId: tvShow.id, - tvdbId: sonarrSeries.tvdbId, - mediaType: MediaType.TV, - serviceId: !server4k ? this.currentServer.id : undefined, - serviceId4k: server4k ? this.currentServer.id : undefined, - externalServiceId: !server4k ? sonarrSeries.id : undefined, - externalServiceId4k: server4k ? sonarrSeries.id : undefined, - externalServiceSlug: !server4k ? sonarrSeries.titleSlug : undefined, - externalServiceSlug4k: server4k ? sonarrSeries.titleSlug : undefined, - seasons: newSeasons, - status: - !server4k && isAllSeasons - ? MediaStatus.AVAILABLE - : !server4k && - newSeasons.some( - (s) => - s.status === MediaStatus.PARTIALLY_AVAILABLE || - s.status === MediaStatus.AVAILABLE - ) - ? MediaStatus.PARTIALLY_AVAILABLE - : !server4k - ? MediaStatus.PROCESSING - : MediaStatus.UNKNOWN, - status4k: - server4k && isAllSeasons - ? MediaStatus.AVAILABLE - : server4k && - newSeasons.some( - (s) => - s.status4k === MediaStatus.PARTIALLY_AVAILABLE || - s.status4k === MediaStatus.AVAILABLE - ) - ? MediaStatus.PARTIALLY_AVAILABLE - : server4k - ? MediaStatus.PROCESSING - : MediaStatus.UNKNOWN, - }); - - this.log( - `Added media for series ${sonarrSeries.title} and set status to ${ - MediaStatus[newMedia[server4k ? 'status4k' : 'status']] - }` - ); - await mediaRepository.save(newMedia); - } - } - - private async processItems(items: SonarrSeries[]) { - await Promise.all( - items.map(async (sonarrSeries) => { - await this.processSonarrSeries(sonarrSeries); - }) - ); - } - - private async loop({ - start = 0, - end = BUNDLE_SIZE, - sessionId, - }: { - start?: number; - end?: number; - sessionId?: string; - } = {}) { - const slicedItems = this.items.slice(start, end); - - if (!this.running) { - throw new Error('Sync was aborted.'); - } - - if (this.sessionId !== sessionId) { - throw new Error('New session was started. Old session aborted.'); - } - - if (start < this.items.length) { - this.progress = start; - await this.processItems(slicedItems); - - await new Promise((resolve, reject) => - setTimeout(() => { - this.loop({ - start: start + BUNDLE_SIZE, - end: end + BUNDLE_SIZE, - sessionId, - }) - .then(() => resolve()) - .catch((e) => reject(new Error(e.message))); - }, UPDATE_RATE) - ); - } - } - - private log( - message: string, - level: 'info' | 'error' | 'debug' | 'warn' = 'debug', - optional?: Record - ): void { - logger[level](message, { label: 'Sonarr Sync', ...optional }); - } -} - -export const jobSonarrSync = new JobSonarrSync(); diff --git a/server/lib/email/index.ts b/server/lib/email/index.ts index c4c6e61a..abbc1632 100644 --- a/server/lib/email/index.ts +++ b/server/lib/email/index.ts @@ -1,8 +1,9 @@ import nodemailer from 'nodemailer'; import Email from 'email-templates'; import { getSettings } from '../settings'; +import { openpgpEncrypt } from './openpgpEncrypt'; class PreparedEmail extends Email { - public constructor() { + public constructor(pgpKey?: string) { const settings = getSettings().notifications.agents.email; const transport = nodemailer.createTransport({ @@ -22,6 +23,16 @@ class PreparedEmail extends Email { } : undefined, }); + if (pgpKey) { + transport.use( + 'stream', + openpgpEncrypt({ + signingKey: settings.options.pgpPrivateKey, + password: settings.options.pgpPassword, + encryptionKeys: [pgpKey], + }) + ); + } super({ message: { from: { diff --git a/server/lib/email/openpgpEncrypt.ts b/server/lib/email/openpgpEncrypt.ts new file mode 100644 index 00000000..146dc73e --- /dev/null +++ b/server/lib/email/openpgpEncrypt.ts @@ -0,0 +1,181 @@ +import * as openpgp from 'openpgp'; +import { Transform, TransformCallback } from 'stream'; +import crypto from 'crypto'; + +interface EncryptorOptions { + signingKey?: string; + password?: string; + encryptionKeys: string[]; +} + +class PGPEncryptor extends Transform { + private _messageChunks: Uint8Array[] = []; + private _messageLength = 0; + private _signingKey?: string; + private _password?: string; + + private _encryptionKeys: string[]; + + constructor(options: EncryptorOptions) { + super(); + this._signingKey = options.signingKey; + this._password = options.password; + this._encryptionKeys = options.encryptionKeys; + } + + // just save the whole message + _transform = ( + chunk: any, + _encoding: BufferEncoding, + callback: TransformCallback + ): void => { + this._messageChunks.push(chunk); + this._messageLength += chunk.length; + callback(); + }; + + // Actually do stuff + _flush = async (callback: TransformCallback): Promise => { + // Reconstruct message as buffer + const message = Buffer.concat(this._messageChunks, this._messageLength); + const validPublicKeys = await Promise.all( + this._encryptionKeys.map((armoredKey) => openpgp.readKey({ armoredKey })) + ); + let privateKey: openpgp.Key | undefined; + + // Just return the message if there is no one to encrypt for + if (!validPublicKeys.length) { + this.push(message); + return callback(); + } + + // Only sign the message if private key and password exist + if (this._signingKey && this._password) { + privateKey = await openpgp.readKey({ + armoredKey: this._signingKey, + }); + await privateKey.decrypt(this._password); + } + + const emailPartDelimiter = '\r\n\r\n'; + const messageParts = message.toString().split(emailPartDelimiter); + + /** + * In this loop original headers are split up into two parts, + * one for the email that is sent + * and one for the encrypted content + */ + const header = messageParts.shift() as string; + const emailHeaders: string[][] = []; + const contentHeaders: string[][] = []; + const linesInHeader = header.split('\r\n'); + let previousHeader: string[] = []; + for (let i = 0; i < linesInHeader.length; i++) { + const line = linesInHeader[i]; + /** + * If it is a multi-line header (current line starts with whitespace) + * or it's the first line in the iteration + * add the current line with previous header and move on + */ + if (/^\s/.test(line) || i === 0) { + previousHeader.push(line); + continue; + } + + /** + * This is done to prevent the last header + * from being missed + */ + if (i === linesInHeader.length - 1) { + previousHeader.push(line); + } + + /** + * We need to seperate the actual content headers + * so that we can add it as a header for the encrypted content + * So that the content will be displayed properly after decryption + */ + if ( + /^(content-type|content-transfer-encoding):/i.test(previousHeader[0]) + ) { + contentHeaders.push(previousHeader); + } else { + emailHeaders.push(previousHeader); + } + previousHeader = [line]; + } + + // Generate a new boundary for the email content + const boundary = 'nm_' + crypto.randomBytes(14).toString('hex'); + /** + * Concatenate everything into single strings + * and add pgp headers to the email headers + */ + const emailHeadersRaw = + emailHeaders.map((line) => line.join('\r\n')).join('\r\n') + + '\r\n' + + 'Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";' + + '\r\n' + + ' boundary="' + + boundary + + '"' + + '\r\n' + + 'Content-Description: OpenPGP encrypted message' + + '\r\n' + + 'Content-Transfer-Encoding: 7bit'; + const contentHeadersRaw = contentHeaders + .map((line) => line.join('\r\n')) + .join('\r\n'); + + const encryptedMessage = await openpgp.encrypt({ + message: openpgp.Message.fromText( + contentHeadersRaw + + emailPartDelimiter + + messageParts.join(emailPartDelimiter) + ), + publicKeys: validPublicKeys, + privateKeys: privateKey, + }); + + const body = + '--' + + boundary + + '\r\n' + + 'Content-Type: application/pgp-encrypted\r\n' + + 'Content-Transfer-Encoding: 7bit\r\n' + + '\r\n' + + 'Version: 1\r\n' + + '\r\n' + + '--' + + boundary + + '\r\n' + + 'Content-Type: application/octet-stream; name=encrypted.asc\r\n' + + 'Content-Disposition: inline; filename=encrypted.asc\r\n' + + 'Content-Transfer-Encoding: 7bit\r\n' + + '\r\n' + + encryptedMessage + + '\r\n--' + + boundary + + '--\r\n'; + + this.push(Buffer.from(emailHeadersRaw + emailPartDelimiter + body)); + callback(); + }; +} + +export const openpgpEncrypt = (options: EncryptorOptions) => { + return function (mail: any, callback: () => unknown): void { + if (!options.encryptionKeys.length) { + setImmediate(callback); + } + mail.message.transform( + () => + new PGPEncryptor({ + signingKey: options.signingKey, + password: options.password, + encryptionKeys: options.encryptionKeys, + }) + ); + setImmediate(callback); + }; +}; diff --git a/server/lib/notifications/agents/agent.ts b/server/lib/notifications/agents/agent.ts index 4db8966a..51791322 100644 --- a/server/lib/notifications/agents/agent.ts +++ b/server/lib/notifications/agents/agent.ts @@ -21,6 +21,12 @@ export abstract class BaseAgent { } protected abstract getSettings(): T; + + protected userNotificationTypes: Notification[] = [ + Notification.MEDIA_APPROVED, + Notification.MEDIA_DECLINED, + Notification.MEDIA_AVAILABLE, + ]; } export interface NotificationAgent { diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index fc6e5bbb..5c02240e 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -71,7 +71,7 @@ interface DiscordRichEmbed { interface DiscordWebhookPayload { embeds: DiscordRichEmbed[]; - username: string; + username?: string; avatar_url?: string; tts: boolean; content?: string; @@ -122,6 +122,7 @@ class DiscordAgent }); break; case Notification.MEDIA_APPROVED: + case Notification.MEDIA_AUTO_APPROVED: color = EmbedColors.PURPLE; fields.push({ name: 'Status', @@ -155,15 +156,14 @@ class DiscordAgent break; } - if (settings.main.applicationUrl && payload.media) { - fields.push({ - name: `Open in ${settings.main.applicationTitle}`, - value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`, - }); - } + const url = + settings.main.applicationUrl && payload.media + ? `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}` + : undefined; return { title: payload.subject, + url, description: payload.message, color, timestamp: new Date().toISOString(), @@ -201,10 +201,13 @@ class DiscordAgent type: Notification, payload: NotificationPayload ): Promise { - logger.debug('Sending discord notification', { label: 'Notifications' }); + logger.debug('Sending Discord notification', { label: 'Notifications' }); try { - const settings = getSettings(); - const webhookUrl = this.getSettings().options.webhookUrl; + const { + botUsername, + botAvatarUrl, + webhookUrl, + } = this.getSettings().options; if (!webhookUrl) { return false; @@ -214,6 +217,7 @@ class DiscordAgent let content = undefined; if ( + this.userNotificationTypes.includes(type) && payload.notifyUser.settings?.enableNotifications && payload.notifyUser.settings?.discordId ) { @@ -222,7 +226,8 @@ class DiscordAgent } await axios.post(webhookUrl, { - username: settings.main.applicationTitle, + username: botUsername, + avatar_url: botAvatarUrl, embeds: [this.buildEmbed(type, payload)], content, allowed_mentions: { diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index 750aaf68..64483c13 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -7,6 +7,7 @@ import { getRepository } from 'typeorm'; import { User } from '../../../entity/User'; import { Permission } from '../../permissions'; import PreparedEmail from '../../email'; +import { MediaType } from '../../../constants/media'; class EmailAgent extends BaseAgent @@ -46,7 +47,7 @@ class EmailAgent users .filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS)) .forEach((user) => { - const email = new PreparedEmail(); + const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey); email.send({ template: path.join( @@ -57,7 +58,9 @@ class EmailAgent to: user.email, }, locals: { - body: 'A user has requested new media!', + body: `A user has requested a new ${ + payload.media?.mediaType === MediaType.TV ? 'series' : 'movie' + }!`, mediaName: payload.subject, imageUrl: payload.image, timestamp: new Date().toTimeString(), @@ -67,13 +70,15 @@ class EmailAgent : undefined, applicationUrl, applicationTitle, - requestType: 'New Request', + requestType: `New ${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request`, }, }); }); return true; } catch (e) { - logger.error('Mail notification failed to send', { + logger.error('Email notification failed to send', { label: 'Notifications', message: e.message, }); @@ -82,6 +87,100 @@ class EmailAgent } private async sendMediaFailedEmail(payload: NotificationPayload) { + // This is getting main settings for the whole app + const { applicationUrl, applicationTitle } = getSettings().main; + try { + const userRepository = getRepository(User); + const users = await userRepository.find(); + + // Send to all users with the manage requests permission (or admins) + users + .filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS)) + .forEach((user) => { + const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey); + + email.send({ + template: path.join( + __dirname, + '../../../templates/email/media-request' + ), + message: { + to: user.email, + }, + locals: { + body: `A new request for the following ${ + payload.media?.mediaType === MediaType.TV ? 'series' : 'movie' + } could not be added to ${ + payload.media?.mediaType === MediaType.TV ? 'Sonarr' : 'Radarr' + }`, + mediaName: payload.subject, + imageUrl: payload.image, + timestamp: new Date().toTimeString(), + requestedBy: payload.notifyUser.displayName, + actionUrl: applicationUrl + ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` + : undefined, + applicationUrl, + applicationTitle, + requestType: `Failed ${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request`, + }, + }); + }); + return true; + } catch (e) { + logger.error('Email notification failed to send', { + label: 'Notifications', + message: e.message, + }); + return false; + } + } + + private async sendMediaApprovedEmail(payload: NotificationPayload) { + // This is getting main settings for the whole app + const { applicationUrl, applicationTitle } = getSettings().main; + try { + const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey); + + await email.send({ + template: path.join( + __dirname, + '../../../templates/email/media-request' + ), + message: { + to: payload.notifyUser.email, + }, + locals: { + body: `Your request for the following ${ + payload.media?.mediaType === MediaType.TV ? 'series' : 'movie' + } has been approved:`, + mediaName: payload.subject, + imageUrl: payload.image, + timestamp: new Date().toTimeString(), + requestedBy: payload.notifyUser.displayName, + actionUrl: applicationUrl + ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` + : undefined, + applicationUrl, + applicationTitle, + requestType: `${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request Approved`, + }, + }); + return true; + } catch (e) { + logger.error('Email notification failed to send', { + label: 'Notifications', + message: e.message, + }); + return false; + } + } + + private async sendMediaAutoApprovedEmail(payload: NotificationPayload) { // This is getting main settings for the whole app const { applicationUrl, applicationTitle } = getSettings().main; try { @@ -103,8 +202,9 @@ class EmailAgent to: user.email, }, locals: { - body: - "A user's new request has failed to add to Sonarr or Radarr", + body: `A new request for the following ${ + payload.media?.mediaType === MediaType.TV ? 'series' : 'movie' + } has been automatically approved:`, mediaName: payload.subject, imageUrl: payload.image, timestamp: new Date().toTimeString(), @@ -114,51 +214,15 @@ class EmailAgent : undefined, applicationUrl, applicationTitle, - requestType: 'Failed Request', + requestType: `${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request Automatically Approved`, }, }); }); return true; } catch (e) { - logger.error('Mail notification failed to send', { - label: 'Notifications', - message: e.message, - }); - return false; - } - } - - private async sendMediaApprovedEmail(payload: NotificationPayload) { - // This is getting main settings for the whole app - const { applicationUrl, applicationTitle } = getSettings().main; - try { - const email = new PreparedEmail(); - - await email.send({ - template: path.join( - __dirname, - '../../../templates/email/media-request' - ), - message: { - to: payload.notifyUser.email, - }, - locals: { - body: 'Your request for the following media has been approved:', - mediaName: payload.subject, - imageUrl: payload.image, - timestamp: new Date().toTimeString(), - requestedBy: payload.notifyUser.displayName, - actionUrl: applicationUrl - ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` - : undefined, - applicationUrl, - applicationTitle, - requestType: 'Request Approved', - }, - }); - return true; - } catch (e) { - logger.error('Mail notification failed to send', { + logger.error('Email notification failed to send', { label: 'Notifications', message: e.message, }); @@ -170,7 +234,7 @@ class EmailAgent // This is getting main settings for the whole app const { applicationUrl, applicationTitle } = getSettings().main; try { - const email = new PreparedEmail(); + const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey); await email.send({ template: path.join( @@ -181,7 +245,9 @@ class EmailAgent to: payload.notifyUser.email, }, locals: { - body: 'Your request for the following media was declined:', + body: `Your request for the following ${ + payload.media?.mediaType === MediaType.TV ? 'series' : 'movie' + } was declined:`, mediaName: payload.subject, imageUrl: payload.image, timestamp: new Date().toTimeString(), @@ -191,12 +257,14 @@ class EmailAgent : undefined, applicationUrl, applicationTitle, - requestType: 'Request Declined', + requestType: `${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request Declined`, }, }); return true; } catch (e) { - logger.error('Mail notification failed to send', { + logger.error('Email notification failed to send', { label: 'Notifications', message: e.message, }); @@ -208,7 +276,7 @@ class EmailAgent // This is getting main settings for the whole app const { applicationUrl, applicationTitle } = getSettings().main; try { - const email = new PreparedEmail(); + const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey); await email.send({ template: path.join( @@ -219,7 +287,9 @@ class EmailAgent to: payload.notifyUser.email, }, locals: { - body: 'Your requested media is now available!', + body: `The following ${ + payload.media?.mediaType === MediaType.TV ? 'series' : 'movie' + } you requested is now available!`, mediaName: payload.subject, imageUrl: payload.image, timestamp: new Date().toTimeString(), @@ -229,12 +299,14 @@ class EmailAgent : undefined, applicationUrl, applicationTitle, - requestType: 'Now Available', + requestType: `${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Now Available`, }, }); return true; } catch (e) { - logger.error('Mail notification failed to send', { + logger.error('Email notification failed to send', { label: 'Notifications', message: e.message, }); @@ -246,7 +318,7 @@ class EmailAgent // This is getting main settings for the whole app const { applicationUrl, applicationTitle } = getSettings().main; try { - const email = new PreparedEmail(); + const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey); await email.send({ template: path.join(__dirname, '../../../templates/email/test-email'), @@ -261,7 +333,7 @@ class EmailAgent }); return true; } catch (e) { - logger.error('Mail notification failed to send', { + logger.error('Email notification failed to send', { label: 'Notifications', message: e.message, }); @@ -282,6 +354,9 @@ class EmailAgent case Notification.MEDIA_APPROVED: this.sendMediaApprovedEmail(payload); break; + case Notification.MEDIA_AUTO_APPROVED: + this.sendMediaAutoApprovedEmail(payload); + break; case Notification.MEDIA_DECLINED: this.sendMediaDeclinedEmail(payload); break; diff --git a/server/lib/notifications/agents/pushbullet.ts b/server/lib/notifications/agents/pushbullet.ts index c7becfab..ef40bff3 100644 --- a/server/lib/notifications/agents/pushbullet.ts +++ b/server/lib/notifications/agents/pushbullet.ts @@ -3,6 +3,7 @@ import { hasNotificationType, Notification } from '..'; import logger from '../../../logger'; import { getSettings, NotificationAgentPushbullet } from '../../settings'; import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import { MediaType } from '../../../constants/media'; interface PushbulletPayload { title: string; @@ -50,7 +51,9 @@ class PushbulletAgent switch (type) { case Notification.MEDIA_PENDING: - messageTitle = 'New Request'; + messageTitle = `New ${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request`; message += `${title}`; if (plot) { message += `\n\n${plot}`; @@ -59,7 +62,20 @@ class PushbulletAgent message += `\nStatus: Pending Approval`; break; case Notification.MEDIA_APPROVED: - messageTitle = 'Request Approved'; + messageTitle = `${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request Approved`; + message += `${title}`; + if (plot) { + message += `\n\n${plot}`; + } + message += `\n\nRequested By: ${username}`; + message += `\nStatus: Processing`; + break; + case Notification.MEDIA_AUTO_APPROVED: + messageTitle = `${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request Automatically Approved`; message += `${title}`; if (plot) { message += `\n\n${plot}`; @@ -68,7 +84,9 @@ class PushbulletAgent message += `\nStatus: Processing`; break; case Notification.MEDIA_AVAILABLE: - messageTitle = 'Now Available'; + messageTitle = `${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Now Available`; message += `${title}`; if (plot) { message += `\n\n${plot}`; @@ -77,7 +95,9 @@ class PushbulletAgent message += `\nStatus: Available`; break; case Notification.MEDIA_DECLINED: - messageTitle = 'Request Declined'; + messageTitle = `${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request Declined`; message += `${title}`; if (plot) { message += `\n\n${plot}`; @@ -86,7 +106,9 @@ class PushbulletAgent message += `\nStatus: Declined`; break; case Notification.MEDIA_FAILED: - messageTitle = 'Failed Request'; + messageTitle = `Failed ${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request`; message += `${title}`; if (plot) { message += `\n\n${plot}`; diff --git a/server/lib/notifications/agents/pushover.ts b/server/lib/notifications/agents/pushover.ts index 19c6d6d9..588b46c7 100644 --- a/server/lib/notifications/agents/pushover.ts +++ b/server/lib/notifications/agents/pushover.ts @@ -3,6 +3,7 @@ import { hasNotificationType, Notification } from '..'; import logger from '../../../logger'; import { getSettings, NotificationAgentPushover } from '../../settings'; import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import { MediaType } from '../../../constants/media'; interface PushoverPayload { token: string; @@ -64,7 +65,9 @@ class PushoverAgent switch (type) { case Notification.MEDIA_PENDING: - messageTitle = 'New Request'; + messageTitle = `New ${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request`; message += `${title}`; if (plot) { message += `\n${plot}`; @@ -73,7 +76,20 @@ class PushoverAgent message += `\n\nStatus\nPending Approval`; break; case Notification.MEDIA_APPROVED: - messageTitle = 'Request Approved'; + messageTitle = `${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request Approved`; + message += `${title}`; + if (plot) { + message += `\n${plot}`; + } + message += `\n\nRequested By\n${username}`; + message += `\n\nStatus\nProcessing`; + break; + case Notification.MEDIA_AUTO_APPROVED: + messageTitle = `${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request Automatically Approved`; message += `${title}`; if (plot) { message += `\n${plot}`; @@ -82,7 +98,9 @@ class PushoverAgent message += `\n\nStatus\nProcessing`; break; case Notification.MEDIA_AVAILABLE: - messageTitle = 'Now Available'; + messageTitle = `${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Now Available`; message += `${title}`; if (plot) { message += `\n${plot}`; @@ -91,7 +109,9 @@ class PushoverAgent message += `\n\nStatus\nAvailable`; break; case Notification.MEDIA_DECLINED: - messageTitle = 'Request Declined'; + messageTitle = `${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request Declined`; message += `${title}`; if (plot) { message += `\n${plot}`; @@ -101,7 +121,9 @@ class PushoverAgent priority = 1; break; case Notification.MEDIA_FAILED: - messageTitle = 'Failed Request'; + messageTitle = `Failed ${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request`; message += `${title}`; if (plot) { message += `\n${plot}`; diff --git a/server/lib/notifications/agents/slack.ts b/server/lib/notifications/agents/slack.ts index 70a527f1..fc6643d6 100644 --- a/server/lib/notifications/agents/slack.ts +++ b/server/lib/notifications/agents/slack.ts @@ -3,6 +3,7 @@ import { hasNotificationType, Notification } from '..'; import logger from '../../../logger'; import { getSettings, NotificationAgentSlack } from '../../settings'; import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; +import { MediaType } from '../../../constants/media'; interface EmbedField { type: 'plain_text' | 'mrkdwn'; @@ -72,35 +73,54 @@ class SlackAgent switch (type) { case Notification.MEDIA_PENDING: - header = 'New Request'; + header = `New ${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request`; fields.push({ type: 'mrkdwn', text: '*Status*\nPending Approval', }); break; case Notification.MEDIA_APPROVED: - header = 'Request Approved'; + header = `${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request Approved`; + fields.push({ + type: 'mrkdwn', + text: '*Status*\nProcessing', + }); + break; + case Notification.MEDIA_AUTO_APPROVED: + header = `${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request Automatically Approved`; fields.push({ type: 'mrkdwn', text: '*Status*\nProcessing', }); break; case Notification.MEDIA_AVAILABLE: - header = 'Now Available'; + header = `${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Now Available`; fields.push({ type: 'mrkdwn', text: '*Status*\nAvailable', }); break; case Notification.MEDIA_DECLINED: - header = 'Request Declined'; + header = `${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request Declined`; fields.push({ type: 'mrkdwn', text: '*Status*\nDeclined', }); break; case Notification.MEDIA_FAILED: - header = 'Failed Request'; + header = `Failed ${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request`; fields.push({ type: 'mrkdwn', text: '*Status*\nFailed', @@ -206,7 +226,7 @@ class SlackAgent type: Notification, payload: NotificationPayload ): Promise { - logger.debug('Sending slack notification', { label: 'Notifications' }); + logger.debug('Sending Slack notification', { label: 'Notifications' }); try { const webhookUrl = this.getSettings().options.webhookUrl; diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts index fd3b4dd9..f26c5cbb 100644 --- a/server/lib/notifications/agents/telegram.ts +++ b/server/lib/notifications/agents/telegram.ts @@ -2,15 +2,24 @@ import axios from 'axios'; import { hasNotificationType, Notification } from '..'; import logger from '../../../logger'; import { getSettings, NotificationAgentTelegram } from '../../settings'; +import { MediaType } from '../../../constants/media'; import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; -interface TelegramPayload { +interface TelegramMessagePayload { text: string; parse_mode: string; chat_id: string; disable_notification: boolean; } +interface TelegramPhotoPayload { + photo: string; + caption: string; + parse_mode: string; + chat_id: string; + disable_notification: boolean; +} + class TelegramAgent extends BaseAgent implements NotificationAgent { @@ -58,7 +67,9 @@ class TelegramAgent /* eslint-disable no-useless-escape */ switch (type) { case Notification.MEDIA_PENDING: - message += `\*New Request\*`; + message += `\*New ${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request\*`; message += `\n\n\*${title}\*`; if (plot) { message += `\n${plot}`; @@ -67,7 +78,20 @@ class TelegramAgent message += `\n\n\*Status\*\nPending Approval`; break; case Notification.MEDIA_APPROVED: - message += `\*Request Approved\*`; + message += `\*${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request Approved\*`; + message += `\n\n\*${title}\*`; + if (plot) { + message += `\n${plot}`; + } + message += `\n\n\*Requested By\*\n${user}`; + message += `\n\n\*Status\*\nProcessing`; + break; + case Notification.MEDIA_AUTO_APPROVED: + message += `\*${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request Automatically Approved\*`; message += `\n\n\*${title}\*`; if (plot) { message += `\n${plot}`; @@ -76,7 +100,9 @@ class TelegramAgent message += `\n\n\*Status\*\nProcessing`; break; case Notification.MEDIA_AVAILABLE: - message += `\*Now Available\*`; + message += `\*${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Now Available\*`; message += `\n\n\*${title}\*`; if (plot) { message += `\n${plot}`; @@ -85,7 +111,9 @@ class TelegramAgent message += `\n\n\*Status\*\nAvailable`; break; case Notification.MEDIA_DECLINED: - message += `\*Request Declined\*`; + message += `\*${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request Declined\*`; message += `\n\n\*${title}\*`; if (plot) { message += `\n${plot}`; @@ -94,7 +122,9 @@ class TelegramAgent message += `\n\n\*Status\*\nDeclined`; break; case Notification.MEDIA_FAILED: - message += `\*Failed Request\*`; + message += `\*Failed ${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request\*`; message += `\n\n\*${title}\*`; if (plot) { message += `\n${plot}`; @@ -121,18 +151,53 @@ class TelegramAgent type: Notification, payload: NotificationPayload ): Promise { - logger.debug('Sending telegram notification', { label: 'Notifications' }); + logger.debug('Sending Telegram notification', { label: 'Notifications' }); try { const endpoint = `${this.baseUrl}bot${ this.getSettings().options.botAPI - }/sendMessage`; + }/${payload.image ? 'sendPhoto' : 'sendMessage'}`; - await axios.post(endpoint, { - text: this.buildMessage(type, payload), - parse_mode: 'MarkdownV2', - chat_id: `${this.getSettings().options.chatId}`, - disable_notification: this.getSettings().options.sendSilently, - } as TelegramPayload); + // Send system notification + await (payload.image + ? axios.post(endpoint, { + photo: payload.image, + caption: this.buildMessage(type, payload), + parse_mode: 'MarkdownV2', + chat_id: `${this.getSettings().options.chatId}`, + disable_notification: this.getSettings().options.sendSilently, + } as TelegramPhotoPayload) + : axios.post(endpoint, { + text: this.buildMessage(type, payload), + parse_mode: 'MarkdownV2', + chat_id: `${this.getSettings().options.chatId}`, + disable_notification: this.getSettings().options.sendSilently, + } as TelegramMessagePayload)); + + // Send user notification + if ( + this.userNotificationTypes.includes(type) && + payload.notifyUser.settings?.enableNotifications && + payload.notifyUser.settings?.telegramChatId && + payload.notifyUser.settings?.telegramChatId !== + this.getSettings().options.chatId + ) { + await (payload.image + ? axios.post(endpoint, { + photo: payload.image, + caption: this.buildMessage(type, payload), + parse_mode: 'MarkdownV2', + chat_id: `${payload.notifyUser.settings.telegramChatId}`, + disable_notification: + payload.notifyUser.settings.telegramSendSilently, + } as TelegramPhotoPayload) + : axios.post(endpoint, { + text: this.buildMessage(type, payload), + parse_mode: 'MarkdownV2', + chat_id: `${payload.notifyUser.settings.telegramChatId}`, + disable_notification: + payload.notifyUser.settings.telegramSendSilently, + } as TelegramMessagePayload)); + } return true; } catch (e) { diff --git a/server/lib/notifications/agents/webhook.ts b/server/lib/notifications/agents/webhook.ts index 6186be49..fa3058fe 100644 --- a/server/lib/notifications/agents/webhook.ts +++ b/server/lib/notifications/agents/webhook.ts @@ -20,6 +20,7 @@ const KeyMap: Record = { notifyuser_email: 'notifyUser.email', notifyuser_avatar: 'notifyUser.avatar', notifyuser_settings_discordId: 'notifyUser.settings.discordId', + notifyuser_settings_telegramChatId: 'notifyUser.settings.telegramChatId', media_tmdbid: 'media.tmdbId', media_imdbid: 'media.imdbId', media_tvdbid: 'media.tvdbId', @@ -137,7 +138,7 @@ class WebhookAgent return true; } catch (e) { - logger.error('Error sending Webhook notification', { + logger.error('Error sending webhook notification', { label: 'Notifications', errorMessage: e.message, }); diff --git a/server/lib/notifications/index.ts b/server/lib/notifications/index.ts index a50a2932..b9cc84a9 100644 --- a/server/lib/notifications/index.ts +++ b/server/lib/notifications/index.ts @@ -9,6 +9,7 @@ export enum Notification { MEDIA_FAILED = 16, TEST_NOTIFICATION = 32, MEDIA_DECLINED = 64, + MEDIA_AUTO_APPROVED = 128, } export const hasNotificationType = ( diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts new file mode 100644 index 00000000..d845a352 --- /dev/null +++ b/server/lib/scanners/baseScanner.ts @@ -0,0 +1,616 @@ +import { getRepository } from 'typeorm'; +import { v4 as uuid } from 'uuid'; +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; +const UPDATE_RATE = 4 * 1000; + +export type StatusBase = { + running: boolean; + progress: number; + total: number; +}; + +export interface RunnableScanner { + run: () => Promise; + status: () => T & StatusBase; +} + +export interface MediaIds { + tmdbId: number; + imdbId?: string; + tvdbId?: number; + isHama?: boolean; +} + +interface ProcessOptions { + is4k?: boolean; + mediaAddedAt?: Date; + ratingKey?: string; + serviceId?: number; + externalServiceId?: number; + externalServiceSlug?: string; + title?: string; + processing?: boolean; +} + +export interface ProcessableSeason { + seasonNumber: number; + totalEpisodes: number; + episodes: number; + episodes4k: number; + is4kOverride?: boolean; + processing?: boolean; +} + +class BaseScanner { + private bundleSize; + private updateRate; + protected progress = 0; + protected items: T[] = []; + protected scannerName: string; + protected enable4kMovie = false; + protected enable4kShow = false; + protected sessionId: string; + protected running = false; + readonly asyncLock = new AsyncLock(); + readonly tmdb = new TheMovieDb(); + + protected constructor( + scannerName: string, + { + updateRate, + bundleSize, + }: { + updateRate?: number; + bundleSize?: number; + } = {} + ) { + this.scannerName = scannerName; + this.bundleSize = bundleSize ?? BUNDLE_SIZE; + this.updateRate = updateRate ?? UPDATE_RATE; + } + + private async getExisting(tmdbId: number, mediaType: MediaType) { + const mediaRepository = getRepository(Media); + + const existing = await mediaRepository.findOne({ + where: { tmdbId: tmdbId, mediaType }, + }); + + return existing; + } + + protected async processMovie( + tmdbId: number, + { + is4k = false, + mediaAddedAt, + ratingKey, + serviceId, + externalServiceId, + externalServiceSlug, + processing = false, + title = 'Unknown Title', + }: ProcessOptions = {} + ): Promise { + const mediaRepository = getRepository(Media); + + await this.asyncLock.dispatch(tmdbId, async () => { + const existing = await this.getExisting(tmdbId, MediaType.MOVIE); + + if (existing) { + let changedExisting = false; + + if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) { + existing[is4k ? 'status4k' : 'status'] = processing + ? MediaStatus.PROCESSING + : MediaStatus.AVAILABLE; + if (mediaAddedAt) { + existing.mediaAddedAt = mediaAddedAt; + } + changedExisting = true; + } + + if (!changedExisting && !existing.mediaAddedAt && mediaAddedAt) { + existing.mediaAddedAt = mediaAddedAt; + changedExisting = true; + } + + if ( + ratingKey && + existing[is4k ? 'ratingKey4k' : 'ratingKey'] !== ratingKey + ) { + existing[is4k ? 'ratingKey4k' : 'ratingKey'] = ratingKey; + changedExisting = true; + } + + if ( + serviceId !== undefined && + existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId + ) { + existing[is4k ? 'serviceId4k' : 'serviceId'] = serviceId; + changedExisting = true; + } + + if ( + externalServiceId !== undefined && + existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] !== + externalServiceId + ) { + existing[ + is4k ? 'externalServiceId4k' : 'externalServiceId' + ] = externalServiceId; + changedExisting = true; + } + + if ( + externalServiceSlug !== undefined && + existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !== + externalServiceSlug + ) { + existing[ + is4k ? 'externalServiceSlug4k' : 'externalServiceSlug' + ] = externalServiceSlug; + changedExisting = true; + } + + if (changedExisting) { + await mediaRepository.save(existing); + this.log( + `Media for ${title} exists. Changed were detected and the title will be updated.`, + 'info' + ); + } else { + this.log(`Title already exists and no changes detected for ${title}`); + } + } else { + const newMedia = new Media(); + newMedia.tmdbId = tmdbId; + + newMedia.status = + !is4k && !processing + ? MediaStatus.AVAILABLE + : !is4k && processing + ? MediaStatus.PROCESSING + : MediaStatus.UNKNOWN; + newMedia.status4k = + is4k && this.enable4kMovie && !processing + ? MediaStatus.AVAILABLE + : is4k && this.enable4kMovie && processing + ? MediaStatus.PROCESSING + : MediaStatus.UNKNOWN; + newMedia.mediaType = MediaType.MOVIE; + newMedia.serviceId = !is4k ? serviceId : undefined; + newMedia.serviceId4k = is4k ? serviceId : undefined; + newMedia.externalServiceId = !is4k ? externalServiceId : undefined; + newMedia.externalServiceId4k = is4k ? externalServiceId : undefined; + newMedia.externalServiceSlug = !is4k ? externalServiceSlug : undefined; + newMedia.externalServiceSlug4k = is4k ? externalServiceSlug : undefined; + + if (mediaAddedAt) { + newMedia.mediaAddedAt = mediaAddedAt; + } + + if (ratingKey) { + newMedia.ratingKey = !is4k ? ratingKey : undefined; + newMedia.ratingKey4k = + is4k && this.enable4kMovie ? ratingKey : undefined; + } + await mediaRepository.save(newMedia); + this.log(`Saved new media: ${title}`); + } + }); + } + + /** + * 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 + * in one method. + * + * Note: If 4k is not enable, ProcessableSeasons should combine their episode counts + * into the normal episodes properties and avoid using the 4k properties. + */ + protected async processShow( + tmdbId: number, + tvdbId: number, + seasons: ProcessableSeason[], + { + mediaAddedAt, + ratingKey, + serviceId, + externalServiceId, + externalServiceSlug, + is4k = false, + title = 'Unknown Title', + }: ProcessOptions = {} + ): Promise { + const mediaRepository = getRepository(Media); + + await this.asyncLock.dispatch(tmdbId, async () => { + const media = await this.getExisting(tmdbId, MediaType.TV); + + const newSeasons: Season[] = []; + + const currentStandardSeasonsAvailable = ( + media?.seasons.filter( + (season) => season.status === MediaStatus.AVAILABLE + ) ?? [] + ).length; + + const current4kSeasonsAvailable = ( + media?.seasons.filter( + (season) => season.status4k === MediaStatus.AVAILABLE + ) ?? [] + ).length; + + for (const season of seasons) { + const existingSeason = media?.seasons.find( + (es) => es.seasonNumber === season.seasonNumber + ); + + // We update the rating keys in the seasons loop because we need episode counts + if (media && season.episodes > 0 && media.ratingKey !== ratingKey) { + media.ratingKey = ratingKey; + } + + if ( + media && + season.episodes4k > 0 && + this.enable4kShow && + media.ratingKey4k !== ratingKey + ) { + media.ratingKey4k = ratingKey; + } + + if (existingSeason) { + // Here we update seasons if they already exist. + // If the season is already marked as available, we + // force it to stay available (to avoid competing scanners) + existingSeason.status = + (season.totalEpisodes === season.episodes && season.episodes > 0) || + existingSeason.status === MediaStatus.AVAILABLE + ? MediaStatus.AVAILABLE + : season.episodes > 0 + ? MediaStatus.PARTIALLY_AVAILABLE + : !season.is4kOverride && season.processing + ? MediaStatus.PROCESSING + : existingSeason.status; + + // Same thing here, except we only do updates if 4k is enabled + existingSeason.status4k = + (this.enable4kShow && + season.episodes4k === season.totalEpisodes && + season.episodes4k > 0) || + existingSeason.status4k === MediaStatus.AVAILABLE + ? MediaStatus.AVAILABLE + : this.enable4kShow && season.episodes4k > 0 + ? MediaStatus.PARTIALLY_AVAILABLE + : season.is4kOverride && season.processing + ? MediaStatus.PROCESSING + : existingSeason.status4k; + } else { + newSeasons.push( + new Season({ + seasonNumber: season.seasonNumber, + status: + season.totalEpisodes === season.episodes && season.episodes > 0 + ? MediaStatus.AVAILABLE + : season.episodes > 0 + ? MediaStatus.PARTIALLY_AVAILABLE + : !season.is4kOverride && season.processing + ? MediaStatus.PROCESSING + : MediaStatus.UNKNOWN, + status4k: + this.enable4kShow && + season.totalEpisodes === season.episodes4k && + season.episodes4k > 0 + ? MediaStatus.AVAILABLE + : this.enable4kShow && season.episodes4k > 0 + ? MediaStatus.PARTIALLY_AVAILABLE + : season.is4kOverride && season.processing + ? MediaStatus.PROCESSING + : MediaStatus.UNKNOWN, + }) + ); + } + } + + const isAllStandardSeasons = + seasons.length && + seasons.every( + (season) => + season.episodes === season.totalEpisodes && season.episodes > 0 + ); + + const isAll4kSeasons = + seasons.length && + seasons.every( + (season) => + season.episodes4k === season.totalEpisodes && season.episodes4k > 0 + ); + + if (media) { + media.seasons = [...media.seasons, ...newSeasons]; + + const newStandardSeasonsAvailable = ( + media.seasons.filter( + (season) => season.status === MediaStatus.AVAILABLE + ) ?? [] + ).length; + + const new4kSeasonsAvailable = ( + media.seasons.filter( + (season) => season.status4k === MediaStatus.AVAILABLE + ) ?? [] + ).length; + + // If at least one new season has become available, update + // the lastSeasonChange field so we can trigger notifications + if (newStandardSeasonsAvailable > currentStandardSeasonsAvailable) { + this.log( + `Detected ${ + newStandardSeasonsAvailable - currentStandardSeasonsAvailable + } new standard season(s) for ${title}`, + 'debug' + ); + media.lastSeasonChange = new Date(); + + if (mediaAddedAt) { + media.mediaAddedAt = mediaAddedAt; + } + } + + if (new4kSeasonsAvailable > current4kSeasonsAvailable) { + this.log( + `Detected ${ + new4kSeasonsAvailable - current4kSeasonsAvailable + } new 4K season(s) for ${title}`, + 'debug' + ); + media.lastSeasonChange = new Date(); + } + + if (!media.mediaAddedAt && mediaAddedAt) { + media.mediaAddedAt = mediaAddedAt; + } + + if (serviceId !== undefined) { + media[is4k ? 'serviceId4k' : 'serviceId'] = serviceId; + } + + if (externalServiceId !== undefined) { + media[ + is4k ? 'externalServiceId4k' : 'externalServiceId' + ] = externalServiceId; + } + + if (externalServiceSlug !== undefined) { + media[ + is4k ? 'externalServiceSlug4k' : 'externalServiceSlug' + ] = externalServiceSlug; + } + + // If the show is already available, and there are no new seasons, dont adjust + // the status + const shouldStayAvailable = + media.status === MediaStatus.AVAILABLE && + newSeasons.filter((season) => season.status !== MediaStatus.UNKNOWN) + .length === 0; + const shouldStayAvailable4k = + media.status4k === MediaStatus.AVAILABLE && + newSeasons.filter((season) => season.status4k !== MediaStatus.UNKNOWN) + .length === 0; + + media.status = + isAllStandardSeasons || shouldStayAvailable + ? MediaStatus.AVAILABLE + : media.seasons.some( + (season) => + season.status === MediaStatus.PARTIALLY_AVAILABLE || + season.status === MediaStatus.AVAILABLE + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : media.seasons.some( + (season) => season.status === MediaStatus.PROCESSING + ) + ? MediaStatus.PROCESSING + : MediaStatus.UNKNOWN; + media.status4k = + (isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow + ? MediaStatus.AVAILABLE + : this.enable4kShow && + media.seasons.some( + (season) => + season.status4k === MediaStatus.PARTIALLY_AVAILABLE || + season.status4k === MediaStatus.AVAILABLE + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : media.seasons.some( + (season) => season.status4k === MediaStatus.PROCESSING + ) + ? MediaStatus.PROCESSING + : MediaStatus.UNKNOWN; + await mediaRepository.save(media); + this.log(`Updating existing title: ${title}`); + } else { + const newMedia = new Media({ + mediaType: MediaType.TV, + seasons: newSeasons, + tmdbId, + tvdbId, + mediaAddedAt, + serviceId: !is4k ? serviceId : undefined, + serviceId4k: is4k ? serviceId : undefined, + externalServiceId: !is4k ? externalServiceId : undefined, + externalServiceId4k: is4k ? externalServiceId : undefined, + externalServiceSlug: !is4k ? externalServiceSlug : undefined, + externalServiceSlug4k: is4k ? externalServiceSlug : undefined, + ratingKey: newSeasons.some( + (sn) => + sn.status === MediaStatus.PARTIALLY_AVAILABLE || + sn.status === MediaStatus.AVAILABLE + ) + ? ratingKey + : undefined, + ratingKey4k: + this.enable4kShow && + newSeasons.some( + (sn) => + sn.status4k === MediaStatus.PARTIALLY_AVAILABLE || + sn.status4k === MediaStatus.AVAILABLE + ) + ? ratingKey + : undefined, + status: isAllStandardSeasons + ? MediaStatus.AVAILABLE + : newSeasons.some( + (season) => + season.status === MediaStatus.PARTIALLY_AVAILABLE || + season.status === MediaStatus.AVAILABLE + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : newSeasons.some( + (season) => season.status === MediaStatus.PROCESSING + ) + ? MediaStatus.PROCESSING + : MediaStatus.UNKNOWN, + status4k: + isAll4kSeasons && this.enable4kShow + ? MediaStatus.AVAILABLE + : this.enable4kShow && + newSeasons.some( + (season) => + season.status4k === MediaStatus.PARTIALLY_AVAILABLE || + season.status4k === MediaStatus.AVAILABLE + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : newSeasons.some( + (season) => season.status4k === MediaStatus.PROCESSING + ) + ? MediaStatus.PROCESSING + : MediaStatus.UNKNOWN, + }); + await mediaRepository.save(newMedia); + this.log(`Saved ${title}`); + } + }); + } + + /** + * Call startRun from child class whenever a run is starting to + * ensure required values are set + * + * Returns the session ID which is requried for the cleanup method + */ + protected startRun(): string { + const settings = getSettings(); + const sessionId = uuid(); + this.sessionId = sessionId; + + this.log('Scan starting', 'info', { sessionId }); + + this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k); + if (this.enable4kMovie) { + this.log( + 'At least one 4K Radarr server was detected. 4K movie detection is now enabled', + 'info' + ); + } + + this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k); + if (this.enable4kShow) { + this.log( + 'At least one 4K Sonarr server was detected. 4K series detection is now enabled', + 'info' + ); + } + + this.running = true; + + return sessionId; + } + + /** + * Call at end of run loop to perform cleanup + */ + protected endRun(sessionId: string): void { + if (this.sessionId === sessionId) { + this.running = false; + } + } + + public cancel(): void { + this.running = false; + } + + protected async loop( + processFn: (item: T) => Promise, + { + start = 0, + end = this.bundleSize, + sessionId, + }: { + start?: number; + end?: number; + sessionId?: string; + } = {} + ): Promise { + const slicedItems = this.items.slice(start, end); + + if (!this.running) { + throw new Error('Sync was aborted.'); + } + + if (this.sessionId !== sessionId) { + throw new Error('New session was started. Old session aborted.'); + } + + if (start < this.items.length) { + this.progress = start; + await this.processItems(processFn, slicedItems); + + await new Promise((resolve, reject) => + setTimeout(() => { + this.loop(processFn, { + start: start + this.bundleSize, + end: end + this.bundleSize, + sessionId, + }) + .then(() => resolve()) + .catch((e) => reject(new Error(e.message))); + }, this.updateRate) + ); + } + } + + private async processItems( + processFn: (items: T) => Promise, + items: T[] + ) { + await Promise.all( + items.map(async (item) => { + await processFn(item); + }) + ); + } + + protected log( + message: string, + level: 'info' | 'error' | 'debug' | 'warn' = 'debug', + optional?: Record + ): void { + logger[level](message, { label: this.scannerName, ...optional }); + } +} + +export default BaseScanner; diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts new file mode 100644 index 00000000..dc136900 --- /dev/null +++ b/server/lib/scanners/plex/index.ts @@ -0,0 +1,463 @@ +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 { getSettings, Library } from '../../settings'; +import BaseScanner, { + MediaIds, + RunnableScanner, + StatusBase, + ProcessableSeason, +} from '../baseScanner'; + +const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/); +const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/); +const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/); +const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/); +const plexRegex = new RegExp(/plex:\/\//); +// Hama agent uses ASS naming, see details here: +// https://github.com/ZeroQI/Absolute-Series-Scanner/blob/master/README.md#forcing-the-movieseries-id +const hamaTvdbRegex = new RegExp(/hama:\/\/tvdb[0-9]?-([0-9]+)/); +const hamaAnidbRegex = new RegExp(/hama:\/\/anidb[0-9]?-([0-9]+)/); +const HAMA_AGENT = 'com.plexapp.agents.hama'; + +type SyncStatus = StatusBase & { + currentLibrary: Library; + libraries: Library[]; +}; + +class PlexScanner + extends BaseScanner + implements RunnableScanner { + private plexClient: PlexAPI; + private libraries: Library[]; + private currentLibrary: Library; + private isRecentOnly = false; + + public constructor(isRecentOnly = false) { + super('Plex Scan'); + this.isRecentOnly = isRecentOnly; + } + + public status(): SyncStatus { + return { + running: this.running, + progress: this.progress, + total: this.items.length, + currentLibrary: this.currentLibrary, + libraries: this.libraries, + }; + } + + public async run(): Promise { + const settings = getSettings(); + const sessionId = this.startRun(); + try { + const userRepository = getRepository(User); + const admin = await userRepository.findOne({ + select: ['id', 'plexToken'], + order: { id: 'ASC' }, + }); + + if (!admin) { + return this.log('No admin configured. Plex scan skipped.', 'warn'); + } + + this.plexClient = new PlexAPI({ plexToken: admin.plexToken }); + + this.libraries = settings.plex.libraries.filter( + (library) => library.enabled + ); + + const hasHama = await this.hasHamaAgent(); + if (hasHama) { + await animeList.sync(); + } + + if (this.isRecentOnly) { + for (const library of this.libraries) { + this.currentLibrary = library; + this.log( + `Beginning to process recently added for library: ${library.name}`, + 'info' + ); + const libraryItems = await this.plexClient.getRecentlyAdded( + library.id + ); + + // Bundle items up by rating keys + this.items = uniqWith(libraryItems, (mediaA, mediaB) => { + if (mediaA.grandparentRatingKey && mediaB.grandparentRatingKey) { + return ( + mediaA.grandparentRatingKey === mediaB.grandparentRatingKey + ); + } + + if (mediaA.parentRatingKey && mediaB.parentRatingKey) { + return mediaA.parentRatingKey === mediaB.parentRatingKey; + } + + return mediaA.ratingKey === mediaB.ratingKey; + }); + + await this.loop(this.processItem.bind(this), { sessionId }); + } + } else { + for (const library of this.libraries) { + this.currentLibrary = library; + this.log(`Beginning to process library: ${library.name}`, 'info'); + this.items = await this.plexClient.getLibraryContents(library.id); + await this.loop(this.processItem.bind(this), { sessionId }); + } + } + this.log( + this.isRecentOnly + ? 'Recently Added Scan Complete' + : 'Full Scan Complete', + 'info' + ); + } catch (e) { + this.log('Scan interrupted', 'error', { errorMessage: e.message }); + } finally { + this.endRun(sessionId); + } + } + + private async processItem(plexitem: PlexLibraryItem) { + try { + if (plexitem.type === 'movie') { + await this.processPlexMovie(plexitem); + } else if ( + plexitem.type === 'show' || + plexitem.type === 'episode' || + plexitem.type === 'season' + ) { + await this.processPlexShow(plexitem); + } + } catch (e) { + this.log('Failed to process Plex media', 'error', { + errorMessage: e.message, + title: plexitem.title, + }); + } + } + + private async processPlexMovie(plexitem: PlexLibraryItem) { + const mediaIds = await this.getMediaIds(plexitem); + const metadata = await this.plexClient.getMetadata(plexitem.ratingKey); + + const has4k = metadata.Media.some( + (media) => media.videoResolution === '4k' + ); + + await this.processMovie(mediaIds.tmdbId, { + is4k: has4k && this.enable4kMovie, + mediaAddedAt: new Date(plexitem.addedAt * 1000), + ratingKey: plexitem.ratingKey, + title: plexitem.title, + }); + } + + private async processPlexMovieByTmdbId( + plexitem: PlexMetadata, + tmdbId: number + ) { + const has4k = plexitem.Media.some( + (media) => media.videoResolution === '4k' + ); + + await this.processMovie(tmdbId, { + is4k: has4k && this.enable4kMovie, + mediaAddedAt: new Date(plexitem.addedAt * 1000), + ratingKey: plexitem.ratingKey, + title: plexitem.title, + }); + } + + private async processPlexShow(plexitem: PlexLibraryItem) { + const ratingKey = + plexitem.grandparentRatingKey ?? + plexitem.parentRatingKey ?? + plexitem.ratingKey; + const metadata = await this.plexClient.getMetadata(ratingKey, { + includeChildren: true, + }); + + const mediaIds = await this.getMediaIds(metadata); + + // If the media is from HAMA, and doesn't have a TVDb ID, we will treat it + // as a special HAMA movie + if (mediaIds.tmdbId && !mediaIds.tvdbId && mediaIds.isHama) { + this.processHamaMovie(metadata, mediaIds.tmdbId); + return; + } + + // If the media is from HAMA and we have a TVDb ID, we will attempt + // to process any specials that may exist + if (mediaIds.tvdbId && mediaIds.isHama) { + await this.processHamaSpecials(metadata, mediaIds.tvdbId); + } + + const tvShow = await this.tmdb.getTvShow({ tvId: mediaIds.tmdbId }); + + const seasons = tvShow.seasons; + const processableSeasons: ProcessableSeason[] = []; + + const filteredSeasons = seasons.filter((sn) => sn.season_number !== 0); + + for (const season of filteredSeasons) { + const matchedPlexSeason = metadata.Children?.Metadata.find( + (md) => Number(md.index) === season.season_number + ); + + if (matchedPlexSeason) { + // If we have a matched Plex season, get its children metadata so we can check details + const episodes = await this.plexClient.getChildrenMetadata( + matchedPlexSeason.ratingKey + ); + // Total episodes that are in standard definition (not 4k) + const totalStandard = episodes.filter((episode) => + !this.enable4kShow + ? true + : episode.Media.some((media) => media.videoResolution !== '4k') + ).length; + + // Total episodes that are in 4k + const total4k = this.enable4kShow + ? episodes.filter((episode) => + episode.Media.some((media) => media.videoResolution === '4k') + ).length + : 0; + + processableSeasons.push({ + seasonNumber: season.season_number, + episodes: totalStandard, + episodes4k: total4k, + totalEpisodes: season.episode_count, + }); + } else { + processableSeasons.push({ + seasonNumber: season.season_number, + episodes: 0, + episodes4k: 0, + totalEpisodes: season.episode_count, + }); + } + } + + if (mediaIds.tvdbId) { + await this.processShow( + mediaIds.tmdbId, + mediaIds.tvdbId ?? tvShow.external_ids.tvdb_id, + processableSeasons, + { + mediaAddedAt: new Date(metadata.addedAt * 1000), + ratingKey: ratingKey, + title: metadata.title, + } + ); + } + } + + private async getMediaIds(plexitem: PlexLibraryItem): Promise { + const mediaIds: Partial = {}; + // Check if item is using new plex movie/tv agent + if (plexitem.guid.match(plexRegex)) { + const metadata = await this.plexClient.getMetadata(plexitem.ratingKey); + + // If there is no Guid field at all, then we bail + if (!metadata.Guid) { + throw new Error( + 'No Guid metadata for this title. Skipping. (Try refreshing the metadata in Plex for this media!)' + ); + } + + // Map all IDs to MediaId object + metadata.Guid.forEach((ref) => { + if (ref.id.match(imdbRegex)) { + mediaIds.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined; + } else if (ref.id.match(tmdbRegex)) { + const tmdbMatch = ref.id.match(tmdbRegex)?.[1]; + mediaIds.tmdbId = Number(tmdbMatch); + } else if (ref.id.match(tvdbRegex)) { + const tvdbMatch = ref.id.match(tvdbRegex)?.[1]; + mediaIds.tvdbId = Number(tvdbMatch); + } + }); + + // If we got an IMDb ID, but no TMDb ID, lookup the TMDb ID with the IMDb ID + if (mediaIds.imdbId && !mediaIds.tmdbId) { + const tmdbMovie = await this.tmdb.getMovieByImdbId({ + imdbId: mediaIds.imdbId, + }); + mediaIds.tmdbId = tmdbMovie.id; + } + // Check if the agent is IMDb + } else if (plexitem.guid.match(imdbRegex)) { + const imdbMatch = plexitem.guid.match(imdbRegex); + if (imdbMatch) { + mediaIds.imdbId = imdbMatch[1]; + const tmdbMovie = await this.tmdb.getMovieByImdbId({ + imdbId: mediaIds.imdbId, + }); + mediaIds.tmdbId = tmdbMovie.id; + } + // Check if the agent is TMDb + } else if (plexitem.guid.match(tmdbRegex)) { + const tmdbMatch = plexitem.guid.match(tmdbRegex); + if (tmdbMatch) { + mediaIds.tmdbId = Number(tmdbMatch[1]); + } + // Check if the agent is TVDb + } else if (plexitem.guid.match(tvdbRegex)) { + const matchedtvdb = plexitem.guid.match(tvdbRegex); + + // If we can find a tvdb Id, use it to get the full tmdb show details + if (matchedtvdb) { + const show = await this.tmdb.getShowByTvdbId({ + tvdbId: Number(matchedtvdb[1]), + }); + + mediaIds.tvdbId = Number(matchedtvdb[1]); + mediaIds.tmdbId = show.id; + } + // Check if the agent (for shows) is TMDb + } else if (plexitem.guid.match(tmdbShowRegex)) { + const matchedtmdb = plexitem.guid.match(tmdbShowRegex); + if (matchedtmdb) { + mediaIds.tmdbId = Number(matchedtmdb[1]); + } + // Check for HAMA (with TVDb guid) + } else if (plexitem.guid.match(hamaTvdbRegex)) { + const matchedtvdb = plexitem.guid.match(hamaTvdbRegex); + + if (matchedtvdb) { + const show = await this.tmdb.getShowByTvdbId({ + tvdbId: Number(matchedtvdb[1]), + }); + + mediaIds.tvdbId = Number(matchedtvdb[1]); + mediaIds.tmdbId = show.id; + // Set isHama to true, so we can know to add special processing to this item + mediaIds.isHama = true; + } + // Check for HAMA (with anidb guid) + } else if (plexitem.guid.match(hamaAnidbRegex)) { + const matchedhama = plexitem.guid.match(hamaAnidbRegex); + + if (!animeList.isLoaded()) { + this.log( + `Hama ID ${plexitem.guid} detected, but library agent is not set to Hama`, + 'warn', + { title: plexitem.title } + ); + } else if (matchedhama) { + const anidbId = Number(matchedhama[1]); + const result = animeList.getFromAnidbId(anidbId); + let tvShow: TmdbTvDetails | null = null; + + // Set isHama to true, so we can know to add special processing to this item + mediaIds.isHama = true; + + // First try to lookup the show by TVDb ID + if (result?.tvdbId) { + const extResponse = await this.tmdb.getByExternalId({ + externalId: result.tvdbId, + type: 'tvdb', + }); + if (extResponse.tv_results[0]) { + tvShow = await this.tmdb.getTvShow({ + tvId: extResponse.tv_results[0].id, + }); + mediaIds.tvdbId = result.tvdbId; + mediaIds.tmdbId = tvShow.id; + } else { + this.log( + `Missing TVDB ${result.tvdbId} entry in TMDB for AniDB ${anidbId}` + ); + } + } + + if (!tvShow) { + // if lookup of tvshow above failed, then try movie with tmdbid/imdbid + // note - some tv shows have imdbid set too, that's why this need to go second + if (result?.tmdbId) { + mediaIds.tmdbId = result.tmdbId; + mediaIds.imdbId = result?.imdbId; + } else if (result?.imdbId) { + const tmdbMovie = await this.tmdb.getMovieByImdbId({ + imdbId: result.imdbId, + }); + mediaIds.tmdbId = tmdbMovie.id; + mediaIds.imdbId = result.imdbId; + } + } + } + } + + if (!mediaIds.tmdbId) { + 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 + return mediaIds as MediaIds; + } + + // movies with hama agent actually are tv shows with at least one episode in it + // try to get first episode of any season - cannot hardcode season or episode number + // because sometimes user can have it in other season/ep than s01e01 + private async processHamaMovie(metadata: PlexMetadata, tmdbId: number) { + const season = metadata.Children?.Metadata[0]; + if (season) { + const episodes = await this.plexClient.getChildrenMetadata( + season.ratingKey + ); + if (episodes) { + await this.processPlexMovieByTmdbId(episodes[0], tmdbId); + } + } + } + + // this adds all movie episodes from specials season for Hama agent + private async processHamaSpecials(metadata: PlexMetadata, tvdbId: number) { + const specials = metadata.Children?.Metadata.find( + (md) => Number(md.index) === 0 + ); + if (specials) { + const episodes = await this.plexClient.getChildrenMetadata( + specials.ratingKey + ); + if (episodes) { + for (const episode of episodes) { + const special = animeList.getSpecialEpisode(tvdbId, episode.index); + if (special) { + if (special.tmdbId) { + await this.processPlexMovieByTmdbId(episode, special.tmdbId); + } else if (special.imdbId) { + const tmdbMovie = await this.tmdb.getMovieByImdbId({ + imdbId: special.imdbId, + }); + await this.processPlexMovieByTmdbId(episode, tmdbMovie.id); + } + } + } + } + } + } + + // checks if any of this.libraries has Hama agent set in Plex + private async hasHamaAgent() { + const plexLibraries = await this.plexClient.getLibraries(); + return this.libraries.some((library) => + plexLibraries.some( + (plexLibrary) => + plexLibrary.agent === HAMA_AGENT && library.id === plexLibrary.key + ) + ); + } +} + +export const plexFullScanner = new PlexScanner(); +export const plexRecentScanner = new PlexScanner(true); diff --git a/server/lib/scanners/radarr/index.ts b/server/lib/scanners/radarr/index.ts new file mode 100644 index 00000000..74682cc5 --- /dev/null +++ b/server/lib/scanners/radarr/index.ts @@ -0,0 +1,94 @@ +import { uniqWith } from 'lodash'; +import RadarrAPI, { RadarrMovie } from '../../../api/radarr'; +import { getSettings, RadarrSettings } from '../../settings'; +import BaseScanner, { RunnableScanner, StatusBase } from '../baseScanner'; + +type SyncStatus = StatusBase & { + currentServer: RadarrSettings; + servers: RadarrSettings[]; +}; + +class RadarrScanner + extends BaseScanner + implements RunnableScanner { + private servers: RadarrSettings[]; + private currentServer: RadarrSettings; + private radarrApi: RadarrAPI; + + constructor() { + super('Radarr Scan', { bundleSize: 50 }); + } + + public status(): SyncStatus { + return { + running: this.running, + progress: this.progress, + total: this.items.length, + currentServer: this.currentServer, + servers: this.servers, + }; + } + + public async run(): Promise { + const settings = getSettings(); + const sessionId = this.startRun(); + + try { + this.servers = uniqWith(settings.radarr, (radarrA, radarrB) => { + return ( + radarrA.hostname === radarrB.hostname && + radarrA.port === radarrB.port && + radarrA.baseUrl === radarrB.baseUrl + ); + }); + + for (const server of this.servers) { + this.currentServer = server; + if (server.syncEnabled) { + this.log( + `Beginning to process Radarr server: ${server.name}`, + 'info' + ); + + this.radarrApi = new RadarrAPI({ + apiKey: server.apiKey, + url: RadarrAPI.buildRadarrUrl(server, '/api/v3'), + }); + + this.items = await this.radarrApi.getMovies(); + + await this.loop(this.processRadarrMovie.bind(this), { sessionId }); + } else { + this.log(`Sync not enabled. Skipping Radarr server: ${server.name}`); + } + } + + this.log('Radarr scan complete', 'info'); + } catch (e) { + this.log('Scan interrupted', 'error', { errorMessage: e.message }); + } finally { + this.endRun(sessionId); + } + } + + private async processRadarrMovie(radarrMovie: RadarrMovie): Promise { + try { + const server4k = this.enable4kMovie && this.currentServer.is4k; + await this.processMovie(radarrMovie.tmdbId, { + is4k: server4k, + serviceId: this.currentServer.id, + externalServiceId: radarrMovie.id, + externalServiceSlug: radarrMovie.titleSlug, + title: radarrMovie.title, + processing: !radarrMovie.downloaded, + }); + } catch (e) { + this.log('Failed to process Radarr media', 'error', { + errorMessage: e.message, + title: radarrMovie.title, + }); + } + } +} + +export const radarrScanner = new RadarrScanner(); diff --git a/server/lib/scanners/sonarr/index.ts b/server/lib/scanners/sonarr/index.ts new file mode 100644 index 00000000..4bc505fb --- /dev/null +++ b/server/lib/scanners/sonarr/index.ts @@ -0,0 +1,134 @@ +import { uniqWith } from 'lodash'; +import { getRepository } from 'typeorm'; +import SonarrAPI, { SonarrSeries } from '../../../api/sonarr'; +import Media from '../../../entity/Media'; +import { getSettings, SonarrSettings } from '../../settings'; +import BaseScanner, { + ProcessableSeason, + RunnableScanner, + StatusBase, +} from '../baseScanner'; + +type SyncStatus = StatusBase & { + currentServer: SonarrSettings; + servers: SonarrSettings[]; +}; + +class SonarrScanner + extends BaseScanner + implements RunnableScanner { + private servers: SonarrSettings[]; + private currentServer: SonarrSettings; + private sonarrApi: SonarrAPI; + + constructor() { + super('Sonarr Scan', { bundleSize: 50 }); + } + + public status(): SyncStatus { + return { + running: this.running, + progress: this.progress, + total: this.items.length, + currentServer: this.currentServer, + servers: this.servers, + }; + } + + public async run(): Promise { + const settings = getSettings(); + const sessionId = this.startRun(); + + try { + this.servers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => { + return ( + sonarrA.hostname === sonarrB.hostname && + sonarrA.port === sonarrB.port && + sonarrA.baseUrl === sonarrB.baseUrl + ); + }); + + for (const server of this.servers) { + this.currentServer = server; + if (server.syncEnabled) { + this.log( + `Beginning to process Sonarr server: ${server.name}`, + 'info' + ); + + this.sonarrApi = new SonarrAPI({ + apiKey: server.apiKey, + url: SonarrAPI.buildSonarrUrl(server, '/api/v3'), + }); + + this.items = await this.sonarrApi.getSeries(); + + await this.loop(this.processSonarrSeries.bind(this), { sessionId }); + } else { + this.log(`Sync not enabled. Skipping Sonarr server: ${server.name}`); + } + } + + this.log('Sonarr scan complete', 'info'); + } catch (e) { + this.log('Scan interrupted', 'error', { errorMessage: e.message }); + } finally { + this.endRun(sessionId); + } + } + + private async processSonarrSeries(sonarrSeries: SonarrSeries) { + try { + const mediaRepository = getRepository(Media); + const server4k = this.enable4kShow && this.currentServer.is4k; + const processableSeasons: ProcessableSeason[] = []; + let tmdbId: number; + + const media = await mediaRepository.findOne({ + where: { tvdbId: sonarrSeries.tvdbId }, + }); + + if (!media || !media.tmdbId) { + const tvShow = await this.tmdb.getShowByTvdbId({ + tvdbId: sonarrSeries.tvdbId, + }); + + tmdbId = tvShow.id; + } else { + tmdbId = media.tmdbId; + } + + const filteredSeasons = sonarrSeries.seasons.filter( + (sn) => sn.seasonNumber !== 0 + ); + + for (const season of filteredSeasons) { + const totalAvailableEpisodes = season.statistics?.episodeFileCount ?? 0; + + processableSeasons.push({ + seasonNumber: season.seasonNumber, + episodes: !server4k ? totalAvailableEpisodes : 0, + episodes4k: server4k ? totalAvailableEpisodes : 0, + totalEpisodes: season.statistics?.totalEpisodeCount ?? 0, + processing: season.monitored && totalAvailableEpisodes === 0, + is4kOverride: server4k, + }); + } + + await this.processShow(tmdbId, sonarrSeries.tvdbId, processableSeasons, { + serviceId: this.currentServer.id, + externalServiceId: sonarrSeries.id, + externalServiceSlug: sonarrSeries.titleSlug, + title: sonarrSeries.title, + is4k: server4k, + }); + } catch (e) { + this.log('Failed to process Sonarr media', 'error', { + errorMessage: e.message, + title: sonarrSeries.title, + }); + } + } +} + +export const sonarrScanner = new SonarrScanner(); diff --git a/server/lib/settings.ts b/server/lib/settings.ts index a65c5ffb..ad0c3dba 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -95,6 +95,8 @@ export interface NotificationAgentConfig { } export interface NotificationAgentDiscord extends NotificationAgentConfig { options: { + botUsername?: string; + botAvatarUrl?: string; webhookUrl: string; }; } @@ -115,11 +117,14 @@ export interface NotificationAgentEmail extends NotificationAgentConfig { authPass?: string; allowSelfSigned: boolean; senderName: string; + pgpPrivateKey?: string; + pgpPassword?: string; }; } export interface NotificationAgentTelegram extends NotificationAgentConfig { options: { + botUsername?: string; botAPI: string; chatId: string; sendSilently: boolean; @@ -160,7 +165,6 @@ interface NotificationAgents { interface NotificationSettings { enabled: boolean; - autoapprovalEnabled: boolean; agents: NotificationAgents; } @@ -210,7 +214,6 @@ class Settings { }, notifications: { enabled: true, - autoapprovalEnabled: false, agents: { email: { enabled: false, @@ -228,6 +231,8 @@ class Settings { enabled: false, types: 0, options: { + botUsername: '', + botAvatarUrl: '', webhookUrl: '', }, }, @@ -242,6 +247,7 @@ class Settings { enabled: false, types: 0, options: { + botUsername: '', botAPI: '', chatId: '', sendSilently: false, diff --git a/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts b/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts new file mode 100644 index 00000000..1e0175cc --- /dev/null +++ b/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTelegramSettingsToUserSettings1614334195680 + implements MigrationInterface { + name = 'AddTelegramSettingsToUserSettings1614334195680'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" 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", "enableNotifications", "discordId", "userId", "region", "originalLanguage") SELECT "id", "enableNotifications", "discordId", "userId", "region", "originalLanguage" 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, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" 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", "enableNotifications", "discordId", "userId", "region", "originalLanguage") SELECT "id", "enableNotifications", "discordId", "userId", "region", "originalLanguage" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + } +} diff --git a/server/migration/1615333940450-AddPGPToUserSettings.ts b/server/migration/1615333940450-AddPGPToUserSettings.ts new file mode 100644 index 00000000..b88e0dca --- /dev/null +++ b/server/migration/1615333940450-AddPGPToUserSettings.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPGPToUserSettings1615333940450 implements MigrationInterface { + name = 'AddPGPToUserSettings1615333940450'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" 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 "temporary_user_settings"("id", "enableNotifications", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently") SELECT "id", "enableNotifications", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently" 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, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" 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 "user_settings"("id", "enableNotifications", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently") SELECT "id", "enableNotifications", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + } +} diff --git a/server/models/Movie.ts b/server/models/Movie.ts index be4828ec..58b4fff6 100644 --- a/server/models/Movie.ts +++ b/server/models/Movie.ts @@ -1,6 +1,7 @@ import type { TmdbMovieDetails, TmdbMovieReleaseResult, + TmdbProductionCompany, } from '../api/themoviedb/interfaces'; import { ProductionCompany, @@ -79,6 +80,18 @@ export interface MovieDetails { plexUrl?: string; } +export const mapProductionCompany = ( + company: TmdbProductionCompany +): ProductionCompany => ({ + id: company.id, + name: company.name, + originCountry: company.origin_country, + description: company.description, + headquarters: company.headquarters, + homepage: company.homepage, + logoPath: company.logo_path, +}); + export const mapMovieDetails = ( movie: TmdbMovieDetails, media?: Media @@ -91,12 +104,7 @@ export const mapMovieDetails = ( originalLanguage: movie.original_language, originalTitle: movie.original_title, popularity: movie.popularity, - productionCompanies: movie.production_companies.map((company) => ({ - id: company.id, - logoPath: company.logo_path, - originCountry: company.origin_country, - name: company.name, - })), + productionCompanies: movie.production_companies.map(mapProductionCompany), productionCountries: movie.production_countries, releaseDate: movie.release_date, releases: movie.release_dates, diff --git a/server/models/Tv.ts b/server/models/Tv.ts index 3631573e..0216aada 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -9,6 +9,7 @@ import { mapExternalIds, Keyword, mapVideos, + TvNetwork, } from './common'; import type { TmdbTvEpisodeResult, @@ -16,6 +17,7 @@ import type { TmdbTvDetails, TmdbSeasonWithEpisodes, TmdbTvRatingResult, + TmdbNetwork, } from '../api/themoviedb/interfaces'; import type Media from '../entity/Media'; import { Video } from './Movie'; @@ -77,7 +79,7 @@ export interface TvDetails { lastEpisodeToAir?: Episode; name: string; nextEpisodeToAir?: Episode; - networks: ProductionCompany[]; + networks: TvNetwork[]; numberOfEpisodes: number; numberOfSeasons: number; originCountry: string[]; @@ -89,6 +91,7 @@ export interface TvDetails { spokenLanguages: SpokenLanguage[]; seasons: Season[]; status: string; + tagline?: string; type: string; voteAverage: number; voteCount: number; @@ -139,6 +142,15 @@ export const mapSeasonWithEpisodes = ( posterPath: season.poster_path, }); +export const mapNetwork = (network: TmdbNetwork): TvNetwork => ({ + id: network.id, + name: network.name, + originCountry: network.origin_country, + headquarters: network.headquarters, + homepage: network.homepage, + logoPath: network.logo_path, +}); + export const mapTvDetails = ( show: TmdbTvDetails, media?: Media @@ -157,17 +169,13 @@ export const mapTvDetails = ( languages: show.languages, lastAirDate: show.last_air_date, name: show.name, - networks: show.networks.map((network) => ({ - id: network.id, - name: network.name, - originCountry: network.origin_country, - logoPath: network.logo_path, - })), + networks: show.networks.map(mapNetwork), numberOfEpisodes: show.number_of_episodes, numberOfSeasons: show.number_of_seasons, originCountry: show.origin_country, originalLanguage: show.original_language, originalName: show.original_name, + tagline: show.tagline, overview: show.overview, popularity: show.popularity, productionCompanies: show.production_companies.map((company) => ({ diff --git a/server/models/common.ts b/server/models/common.ts index d26cf637..be276562 100644 --- a/server/models/common.ts +++ b/server/models/common.ts @@ -14,6 +14,18 @@ export interface ProductionCompany { logoPath?: string; originCountry: string; name: string; + description?: string; + headquarters?: string; + homepage?: string; +} + +export interface TvNetwork { + id: number; + logoPath?: string; + originCountry?: string; + name: string; + headquarters?: string; + homepage?: string; } export interface Keyword { diff --git a/server/routes/discover.ts b/server/routes/discover.ts index e248870a..c46048ae 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -6,6 +6,8 @@ import { isMovie, isPerson } from '../utils/typeHelpers'; import { MediaType } from '../constants/media'; import { getSettings } from '../lib/settings'; import { User } from '../entity/User'; +import { mapProductionCompany } from '../models/Movie'; +import { mapNetwork } from '../models/Tv'; const createTmdbWithRegionLanaguage = (user?: User): TheMovieDb => { const settings = getSettings(); @@ -38,6 +40,8 @@ discoverRoutes.get('/movies', async (req, res) => { const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), language: req.query.language as string, + genre: req.query.genre ? Number(req.query.genre) : undefined, + studio: req.query.studio ? Number(req.query.studio) : undefined, }); const media = await Media.getRelatedMedia( @@ -59,6 +63,133 @@ discoverRoutes.get('/movies', async (req, res) => { }); }); +discoverRoutes.get<{ language: string }>( + '/movies/language/:language', + async (req, res, next) => { + const tmdb = createTmdbWithRegionLanaguage(req.user); + + const languages = await tmdb.getLanguages(); + + const language = languages.find( + (lang) => lang.iso_639_1 === req.params.language + ); + + if (!language) { + return next({ status: 404, message: 'Unable to retrieve language' }); + } + + const data = await tmdb.getDiscoverMovies({ + page: Number(req.query.page), + language: req.query.language as string, + originalLanguage: req.params.language, + }); + + const media = await Media.getRelatedMedia( + data.results.map((result) => result.id) + ); + + return res.status(200).json({ + page: data.page, + totalPages: data.total_pages, + totalResults: data.total_results, + language, + results: data.results.map((result) => + mapMovieResult( + result, + media.find( + (req) => + req.tmdbId === result.id && req.mediaType === MediaType.MOVIE + ) + ) + ), + }); + } +); + +discoverRoutes.get<{ genreId: string }>( + '/movies/genre/:genreId', + async (req, res, next) => { + const tmdb = createTmdbWithRegionLanaguage(req.user); + + const genres = await tmdb.getMovieGenres({ + language: req.query.language as string, + }); + + const genre = genres.find( + (genre) => genre.id === Number(req.params.genreId) + ); + + if (!genre) { + return next({ status: 404, message: 'Unable to retrieve genre' }); + } + + const data = await tmdb.getDiscoverMovies({ + page: Number(req.query.page), + language: req.query.language as string, + genre: Number(req.params.genreId), + }); + + const media = await Media.getRelatedMedia( + data.results.map((result) => result.id) + ); + + return res.status(200).json({ + page: data.page, + totalPages: data.total_pages, + totalResults: data.total_results, + genre, + results: data.results.map((result) => + mapMovieResult( + result, + media.find( + (req) => + req.tmdbId === result.id && req.mediaType === MediaType.MOVIE + ) + ) + ), + }); + } +); + +discoverRoutes.get<{ studioId: string }>( + '/movies/studio/:studioId', + async (req, res, next) => { + const tmdb = new TheMovieDb(); + + try { + const studio = await tmdb.getStudio(Number(req.params.studioId)); + + const data = await tmdb.getDiscoverMovies({ + page: Number(req.query.page), + language: req.query.language as string, + studio: Number(req.params.studioId), + }); + + const media = await Media.getRelatedMedia( + data.results.map((result) => result.id) + ); + + return res.status(200).json({ + page: data.page, + totalPages: data.total_pages, + totalResults: data.total_results, + studio: mapProductionCompany(studio), + results: data.results.map((result) => + mapMovieResult( + result, + media.find( + (med) => + med.tmdbId === result.id && med.mediaType === MediaType.MOVIE + ) + ) + ), + }); + } catch (e) { + return next({ status: 404, message: 'Unable to retrieve studio' }); + } + } +); + discoverRoutes.get('/movies/upcoming', async (req, res) => { const tmdb = createTmdbWithRegionLanaguage(req.user); @@ -99,6 +230,8 @@ discoverRoutes.get('/tv', async (req, res) => { const data = await tmdb.getDiscoverTv({ page: Number(req.query.page), language: req.query.language as string, + genre: req.query.genre ? Number(req.query.genre) : undefined, + network: req.query.network ? Number(req.query.network) : undefined, }); const media = await Media.getRelatedMedia( @@ -120,6 +253,131 @@ discoverRoutes.get('/tv', async (req, res) => { }); }); +discoverRoutes.get<{ language: string }>( + '/tv/language/:language', + async (req, res, next) => { + const tmdb = createTmdbWithRegionLanaguage(req.user); + + const languages = await tmdb.getLanguages(); + + const language = languages.find( + (lang) => lang.iso_639_1 === req.params.language + ); + + if (!language) { + return next({ status: 404, message: 'Unable to retrieve language' }); + } + + const data = await tmdb.getDiscoverTv({ + page: Number(req.query.page), + language: req.query.language as string, + originalLanguage: req.params.language, + }); + + const media = await Media.getRelatedMedia( + data.results.map((result) => result.id) + ); + + return res.status(200).json({ + page: data.page, + totalPages: data.total_pages, + totalResults: data.total_results, + language, + results: data.results.map((result) => + mapTvResult( + result, + media.find( + (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV + ) + ) + ), + }); + } +); + +discoverRoutes.get<{ genreId: string }>( + '/tv/genre/:genreId', + async (req, res, next) => { + const tmdb = createTmdbWithRegionLanaguage(req.user); + + const genres = await tmdb.getTvGenres({ + language: req.query.language as string, + }); + + const genre = genres.find( + (genre) => genre.id === Number(req.params.genreId) + ); + + if (!genre) { + return next({ status: 404, message: 'Unable to retrieve genre' }); + } + + const data = await tmdb.getDiscoverTv({ + page: Number(req.query.page), + language: req.query.language as string, + genre: Number(req.params.genreId), + }); + + const media = await Media.getRelatedMedia( + data.results.map((result) => result.id) + ); + + return res.status(200).json({ + page: data.page, + totalPages: data.total_pages, + totalResults: data.total_results, + genre, + results: data.results.map((result) => + mapTvResult( + result, + media.find( + (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV + ) + ) + ), + }); + } +); + +discoverRoutes.get<{ networkId: string }>( + '/tv/network/:networkId', + async (req, res, next) => { + const tmdb = new TheMovieDb(); + + try { + const network = await tmdb.getNetwork(Number(req.params.networkId)); + + const data = await tmdb.getDiscoverTv({ + page: Number(req.query.page), + language: req.query.language as string, + network: Number(req.params.networkId), + }); + + const media = await Media.getRelatedMedia( + data.results.map((result) => result.id) + ); + + return res.status(200).json({ + page: data.page, + totalPages: data.total_pages, + totalResults: data.total_results, + network: mapNetwork(network), + results: data.results.map((result) => + mapTvResult( + result, + media.find( + (med) => + med.tmdbId === result.id && med.mediaType === MediaType.TV + ) + ) + ), + }); + } catch (e) { + return next({ status: 404, message: 'Unable to retrieve network' }); + } + } +); + discoverRoutes.get('/tv/upcoming', async (req, res) => { const tmdb = createTmdbWithRegionLanaguage(req.user); @@ -175,15 +433,18 @@ discoverRoutes.get('/trending', async (req, res) => { ? mapMovieResult( result, media.find( - (req) => - req.tmdbId === result.id && req.mediaType === MediaType.MOVIE + (med) => + med.tmdbId === result.id && med.mediaType === MediaType.MOVIE ) ) : isPerson(result) ? mapPersonResult(result) : mapTvResult( result, - media.find((req) => req.tmdbId === result.id && MediaType.TV) + media.find( + (med) => + med.tmdbId === result.id && med.mediaType === MediaType.TV + ) ) ), }); @@ -212,8 +473,8 @@ discoverRoutes.get<{ keywordId: string }>( mapMovieResult( result, media.find( - (req) => - req.tmdbId === result.id && req.mediaType === MediaType.MOVIE + (med) => + med.tmdbId === result.id && med.mediaType === MediaType.MOVIE ) ) ), diff --git a/server/routes/index.ts b/server/routes/index.ts index 7527c030..af9537db 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -17,6 +17,8 @@ import { getAppVersion, getCommitTag } from '../utils/appVersion'; import serviceRoutes from './service'; import { appDataStatus, appDataPath } from '../utils/appDataVolume'; import TheMovieDb from '../api/themoviedb'; +import { mapProductionCompany } from '../models/Movie'; +import { mapNetwork } from '../models/Tv'; const router = Router(); @@ -74,6 +76,42 @@ router.get('/languages', isAuthenticated(), async (req, res) => { return res.status(200).json(languages); }); +router.get<{ id: string }>('/studio/:id', async (req, res) => { + const tmdb = new TheMovieDb(); + + const studio = await tmdb.getStudio(Number(req.params.id)); + + return res.status(200).json(mapProductionCompany(studio)); +}); + +router.get<{ id: string }>('/network/:id', async (req, res) => { + const tmdb = new TheMovieDb(); + + const network = await tmdb.getNetwork(Number(req.params.id)); + + return res.status(200).json(mapNetwork(network)); +}); + +router.get('/genres/movie', isAuthenticated(), async (req, res) => { + const tmdb = new TheMovieDb(); + + const genres = await tmdb.getMovieGenres({ + language: req.query.language as string, + }); + + return res.status(200).json(genres); +}); + +router.get('/genres/tv', isAuthenticated(), async (req, res) => { + const tmdb = new TheMovieDb(); + + const genres = await tmdb.getTvGenres({ + language: req.query.language as string, + }); + + return res.status(200).json(genres); +}); + router.get('/', (_req, res) => { return res.status(200).json({ api: 'Overseerr API', diff --git a/server/routes/request.ts b/server/routes/request.ts index afec0d25..08597521 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -211,32 +211,34 @@ requestRoutes.post( media, requestedBy: requestUser, // If the user is an admin or has the "auto approve" permission, automatically approve the request - status: - req.user?.hasPermission( + status: req.user?.hasPermission( + [ req.body.is4k ? Permission.AUTO_APPROVE_4K - : Permission.AUTO_APPROVE - ) || - req.user?.hasPermission( + : Permission.AUTO_APPROVE, req.body.is4k ? Permission.AUTO_APPROVE_4K_MOVIE - : Permission.AUTO_APPROVE_MOVIE - ) - ? MediaRequestStatus.APPROVED - : MediaRequestStatus.PENDING, - modifiedBy: - req.user?.hasPermission( + : 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.user?.hasPermission( + : Permission.AUTO_APPROVE, req.body.is4k ? Permission.AUTO_APPROVE_4K_MOVIE - : Permission.AUTO_APPROVE_MOVIE - ) - ? req.user - : undefined, + : Permission.AUTO_APPROVE_MOVIE, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? req.user + : undefined, is4k: req.body.is4k, serverId: req.body.serverId, profileId: req.body.profileId, @@ -286,32 +288,34 @@ requestRoutes.post( media, requestedBy: requestUser, // If the user is an admin or has the "auto approve" permission, automatically approve the request - status: - req.user?.hasPermission( + status: req.user?.hasPermission( + [ req.body.is4k ? Permission.AUTO_APPROVE_4K - : Permission.AUTO_APPROVE - ) || - req.user?.hasPermission( + : Permission.AUTO_APPROVE, req.body.is4k ? Permission.AUTO_APPROVE_4K_TV - : Permission.AUTO_APPROVE_TV - ) - ? MediaRequestStatus.APPROVED - : MediaRequestStatus.PENDING, - modifiedBy: - req.user?.hasPermission( + : 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.user?.hasPermission( + : Permission.AUTO_APPROVE, req.body.is4k ? Permission.AUTO_APPROVE_4K_TV - : Permission.AUTO_APPROVE_TV - ) - ? req.user - : undefined, + : Permission.AUTO_APPROVE_TV, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? req.user + : undefined, is4k: req.body.is4k, serverId: req.body.serverId, profileId: req.body.profileId, @@ -321,19 +325,20 @@ requestRoutes.post( (sn) => new SeasonRequest({ seasonNumber: sn, - status: - req.user?.hasPermission( + status: req.user?.hasPermission( + [ req.body.is4k ? Permission.AUTO_APPROVE_4K - : Permission.AUTO_APPROVE - ) || - req.user?.hasPermission( + : Permission.AUTO_APPROVE, req.body.is4k ? Permission.AUTO_APPROVE_4K_TV - : Permission.AUTO_APPROVE_TV - ) - ? MediaRequestStatus.APPROVED - : MediaRequestStatus.PENDING, + : Permission.AUTO_APPROVE_TV, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, }) ), }); diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 0099d28c..a7dbd3c1 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -4,7 +4,6 @@ import { getRepository } from 'typeorm'; import { User } from '../../entity/User'; import PlexAPI from '../../api/plexapi'; import PlexTvAPI from '../../api/plextv'; -import { jobPlexFullSync } from '../../job/plexsync'; import { scheduledJobs } from '../../job/schedule'; import { Permission } from '../../lib/permissions'; import { isAuthenticated } from '../../middleware/auth'; @@ -17,6 +16,7 @@ import notificationRoutes from './notifications'; import sonarrRoutes from './sonarr'; import radarrRoutes from './radarr'; import cacheManager, { AvailableCacheIds } from '../../lib/cache'; +import { plexFullScanner } from '../../lib/scanners/plex'; const settingsRoutes = Router(); @@ -211,16 +211,16 @@ settingsRoutes.get('/plex/library', async (req, res) => { }); settingsRoutes.get('/plex/sync', (_req, res) => { - return res.status(200).json(jobPlexFullSync.status()); + return res.status(200).json(plexFullScanner.status()); }); settingsRoutes.post('/plex/sync', (req, res) => { if (req.body.cancel) { - jobPlexFullSync.cancel(); + plexFullScanner.cancel(); } else if (req.body.start) { - jobPlexFullSync.run(); + plexFullScanner.run(); } - return res.status(200).json(jobPlexFullSync.status()); + return res.status(200).json(plexFullScanner.status()); }); settingsRoutes.get('/jobs', (_req, res) => { diff --git a/server/routes/settings/notifications.ts b/server/routes/settings/notifications.ts index 58be3a4f..fbf1ce1e 100644 --- a/server/routes/settings/notifications.ts +++ b/server/routes/settings/notifications.ts @@ -15,7 +15,6 @@ notificationRoutes.get('/', (_req, res) => { const settings = getSettings().notifications; return res.status(200).json({ enabled: settings.enabled, - autoapprovalEnabled: settings.autoapprovalEnabled, }); }); @@ -24,13 +23,11 @@ notificationRoutes.post('/', (req, res) => { Object.assign(settings.notifications, { enabled: req.body.enabled, - autoapprovalEnabled: req.body.autoapprovalEnabled, }); settings.save(); return res.status(200).json({ enabled: settings.notifications.enabled, - autoapprovalEnabled: settings.notifications.autoapprovalEnabled, }); }); diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 803aed7c..d29567a6 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -167,7 +167,10 @@ router.get<{ id: string }, UserRequestsResponse>( } ); -const canMakePermissionsChange = (permissions: number, user?: User) => +export const canMakePermissionsChange = ( + permissions: number, + 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 diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index c2e07511..02ff2c72 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -1,6 +1,8 @@ import { Router } from 'express'; import { getRepository } from 'typeorm'; +import { canMakePermissionsChange } from '.'; import { User } from '../../entity/User'; +import { getSettings } from '../../lib/settings'; import { UserSettings } from '../../entity/UserSettings'; import { UserSettingsGeneralResponse, @@ -21,6 +23,7 @@ const isOwnProfileOrAdmin = (): Middleware => { message: "You do not have permission to view this user's settings.", }); } + next(); }; return authMiddleware; @@ -70,6 +73,14 @@ userSettingsRoutes.post< return next({ status: 404, message: 'User not found.' }); } + // "Owner" user settings cannot be modified by other users + if (user.id === 1 && req.user?.id !== 1) { + return next({ + status: 403, + message: "You do not have permission to modify this user's settings.", + }); + } + user.username = req.body.username; if (!user.settings) { user.settings = new UserSettings({ @@ -137,7 +148,19 @@ userSettingsRoutes.post< if (req.body.newPassword.length < 8) { return next({ status: 400, - message: 'Password must be at least 8 characters', + message: 'Password must be at least 8 characters.', + }); + } + + if ( + (user.id === 1 && req.user?.id !== 1) || + (user.hasPermission(Permission.ADMIN) && + user.id !== req.user?.id && + req.user?.id !== 1) + ) { + return next({ + status: 403, + message: "You do not have permission to modify this user's password.", }); } @@ -184,6 +207,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( isOwnProfileOrAdmin(), async (req, res, next) => { const userRepository = getRepository(User); + const settings = getSettings(); try { const user = await userRepository.findOne({ @@ -196,7 +220,12 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( return res.status(200).json({ enableNotifications: user.settings?.enableNotifications ?? true, + telegramBotUsername: + settings?.notifications.agents.telegram.options.botUsername, discordId: user.settings?.discordId, + telegramChatId: user.settings?.telegramChatId, + telegramSendSilently: user?.settings?.telegramSendSilently, + pgpKey: user?.settings?.pgpKey, }); } catch (e) { next({ status: 500, message: e.message }); @@ -220,15 +249,29 @@ userSettingsRoutes.post< return next({ status: 404, message: 'User not found.' }); } + // "Owner" user settings cannot be modified by other users + if (user.id === 1 && req.user?.id !== 1) { + return next({ + status: 403, + message: "You do not have permission to modify this user's settings.", + }); + } + if (!user.settings) { user.settings = new UserSettings({ user: req.user, enableNotifications: req.body.enableNotifications, discordId: req.body.discordId, + telegramChatId: req.body.telegramChatId, + telegramSendSilently: req.body.telegramSendSilently, + pgpKey: req.body.pgpKey, }); } else { user.settings.enableNotifications = req.body.enableNotifications; user.settings.discordId = req.body.discordId; + user.settings.telegramChatId = req.body.telegramChatId; + user.settings.telegramSendSilently = req.body.telegramSendSilently; + user.settings.pgpKey = req.body.pgpKey; } userRepository.save(user); @@ -236,6 +279,9 @@ userSettingsRoutes.post< return res.status(200).json({ enableNotifications: user.settings.enableNotifications, discordId: user.settings.discordId, + telegramChatId: user.settings.telegramChatId, + telegramSendSilently: user.settings.telegramSendSilently, + pgpKey: user.settings.pgpKey, }); } catch (e) { next({ status: 500, message: e.message }); @@ -283,13 +329,20 @@ userSettingsRoutes.post< return next({ status: 404, message: 'User not found.' }); } - if (user.id === 1) { + // "Owner" user permissions cannot be modified, and users cannot set their own permissions + if (user.id === 1 || req.user?.id === user.id) { return next({ - status: 500, - message: 'Permissions for user with ID 1 cannot be modified', + status: 403, + message: 'You do not have permission to modify this user', }); } + if (!canMakePermissionsChange(req.body.permissions, req.user)) { + return next({ + status: 403, + message: 'You do not have permission to grant this level of access', + }); + } user.permissions = req.body.permissions; await userRepository.save(user); diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 7aad6406..a83561a0 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -11,7 +11,7 @@ confinement: strict parts: overseerr: plugin: nodejs - nodejs-version: "12.18.4" + nodejs-version: "14.16.0" nodejs-package-manager: "yarn" nodejs-yarn-version: v1.22.5 build-packages: diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index 5148a309..5953df1e 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -19,12 +19,14 @@ import Transition from '../Transition'; import PageTitle from '../Common/PageTitle'; import { useUser, Permission } from '../../hooks/useUser'; import useSettings from '../../hooks/useSettings'; +import Link from 'next/link'; +import { uniq } from 'lodash'; const messages = defineMessages({ overviewunavailable: 'Overview unavailable.', overview: 'Overview', movies: 'Movies', - numberofmovies: 'Number of Movies: {count}', + numberofmovies: '{count} Movies', requesting: 'Requesting…', request: 'Request', requestcollection: 'Request Collection', @@ -62,6 +64,10 @@ const CollectionDetails: React.FC = ({ } ); + const { data: genres } = useSWR<{ id: number; name: string }[]>( + `/api/v1/genres/movie?language=${locale}` + ); + if (!data && !error) { return ; } @@ -105,6 +111,17 @@ const CollectionDetails: React.FC = ({ collectionStatus4k = MediaStatus.PARTIALLY_AVAILABLE; } + const hasRequestable = + data.parts.filter( + (part) => !part.mediaInfo || part.mediaInfo.status === MediaStatus.UNKNOWN + ).length > 0; + + const hasRequestable4k = + data.parts.filter( + (part) => + !part.mediaInfo || part.mediaInfo.status4k === MediaStatus.UNKNOWN + ).length > 0; + const requestableParts = data.parts.filter( (part) => !part.mediaInfo || @@ -147,9 +164,43 @@ const CollectionDetails: React.FC = ({ } }; + const collectionAttributes: React.ReactNode[] = []; + + collectionAttributes.push( + intl.formatMessage(messages.numberofmovies, { + count: data.parts.length, + }) + ); + + if (genres && data.parts.some((part) => part.genreIds.length)) { + collectionAttributes.push( + uniq( + data.parts.reduce( + (genresList: number[], curr) => genresList.concat(curr.genreIds), + [] + ) + ) + .map((genreId) => ( + + + {genres.find((g) => g.id === genreId)?.name} + + + )) + .reduce((prev, curr) => ( + <> + {prev}, {curr} + + )) + ); + } + return (
= ({ -
-
- -
-
-
- - (part.mediaInfo?.downloadStatus ?? []).length > 0 - )} - /> - +
+ +
+
+ (part.mediaInfo?.downloadStatus ?? []).length > 0 + )} + /> {settings.currentSettings.movie4kEnabled && hasPermission( [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], @@ -241,43 +288,83 @@ const CollectionDetails: React.FC = ({ type: 'or', } ) && ( - - - (part.mediaInfo?.downloadStatus4k ?? []).length > 0 - )} - /> - + + (part.mediaInfo?.downloadStatus4k ?? []).length > 0 + )} + /> )}
-

{data.name}

- - {intl.formatMessage(messages.numberofmovies, { - count: data.parts.length, - })} +

{data.name}

+ + {collectionAttributes.length > 0 && + collectionAttributes + .map((t, k) => {t}) + .reduce((prev, curr) => ( + <> + {prev} | {curr} + + ))}
-
+
{hasPermission(Permission.REQUEST) && - (collectionStatus !== MediaStatus.AVAILABLE || + (hasRequestable || (settings.currentSettings.movie4kEnabled && hasPermission( [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], { type: 'or' } ) && - collectionStatus4k !== MediaStatus.AVAILABLE)) && ( -
- { - setRequestModal(true); - setIs4k(collectionStatus === MediaStatus.AVAILABLE); - }} - text={ - <> + hasRequestable4k)) && ( + { + setRequestModal(true); + setIs4k(!hasRequestable); + }} + text={ + <> + + + + + {intl.formatMessage( + hasRequestable + ? messages.requestcollection + : messages.requestcollection4k + )} + + + } + > + {settings.currentSettings.movie4kEnabled && + hasPermission( + [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], + { type: 'or' } + ) && + hasRequestable && + hasRequestable4k && ( + { + setRequestModal(true); + setIs4k(true); + }} + > = ({ /> - {intl.formatMessage( - collectionStatus === MediaStatus.AVAILABLE - ? messages.requestcollection4k - : messages.requestcollection - )} + {intl.formatMessage(messages.requestcollection4k)} - - } - > - {settings.currentSettings.movie4kEnabled && - hasPermission( - [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], - { type: 'or' } - ) && - collectionStatus !== MediaStatus.AVAILABLE && - collectionStatus4k !== MediaStatus.AVAILABLE && ( - { - setRequestModal(true); - setIs4k(true); - }} - > - - - - - {intl.formatMessage(messages.requestcollection4k)} - - - )} - -
+ + )} + )}
-
-
-

- {intl.formatMessage(messages.overview)} -

-

+

+
+

{intl.formatMessage(messages.overview)}

+

{data.overview ? data.overview : intl.formatMessage(messages.overviewunavailable)}

-
-
-
- {intl.formatMessage(messages.movies)} -
+
+
+ {intl.formatMessage(messages.movies)}
( ref?: React.Ref> ): JSX.Element { const buttonStyle = [ - 'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer', + 'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer disabled:opacity-50', ]; switch (buttonType) { case 'primary': buttonStyle.push( - 'text-white bg-indigo-600 hover:bg-indigo-500 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 disabled:opacity-50' + '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' ); break; case 'danger': buttonStyle.push( - 'text-white bg-red-600 hover:bg-red-500 focus:border-red-700 focus:ring-red active:bg-red-700 disabled:opacity-50' + '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' ); break; case 'warning': buttonStyle.push( - 'text-white bg-yellow-500 hover:bg-yellow-400 focus:border-yellow-700 focus:ring-yellow active:bg-yellow-700 disabled:opacity-50' + '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' ); break; case 'success': buttonStyle.push( - 'text-white bg-green-400 hover:bg-green-300 focus:border-green-700 focus:ring-green active:bg-green-700 disabled:opacity-50' + 'text-white bg-green-400 border-green-400 hover:bg-green-300 hover:border-green-300 focus:border-green-700 focus:ring-green active:bg-green-700 active:border-green-700' ); break; case 'ghost': buttonStyle.push( - 'text-white bg-transaprent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100 disabled:opacity-50' + 'text-white bg-transaprent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100' ); break; default: buttonStyle.push( - 'leading-5 font-medium rounded-md text-gray-200 bg-gray-500 hover:bg-gray-400 group-hover:bg-gray-400 hover:text-white group-hover:text-white focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-400 disabled:opacity-50' + 'text-gray-200 bg-gray-500 border-gray-500 hover:text-white hover:bg-gray-400 hover:border-gray-400 group-hover:text-white group-hover:bg-gray-400 group-hover:border-gray-400 focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-400 active:border-gray-400' ); } diff --git a/src/components/Common/ButtonWithDropdown/index.tsx b/src/components/Common/ButtonWithDropdown/index.tsx index 054ecaea..d81ea3fa 100644 --- a/src/components/Common/ButtonWithDropdown/index.tsx +++ b/src/components/Common/ButtonWithDropdown/index.tsx @@ -59,24 +59,23 @@ const ButtonWithDropdown: React.FC = ({ useClickOutside(buttonRef, () => setIsOpen(false)); const styleClasses = { - mainButtonClasses: '', - dropdownSideButtonClasses: '', + mainButtonClasses: 'text-white border', + dropdownSideButtonClasses: 'border', dropdownClasses: '', }; switch (buttonType) { case 'ghost': - styleClasses.mainButtonClasses = - 'text-white bg-transparent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'; - styleClasses.dropdownSideButtonClasses = - 'bg-transparent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'; + 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'; break; default: - styleClasses.mainButtonClasses = - 'text-white bg-indigo-600 hover:text-white hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue'; - styleClasses.dropdownSideButtonClasses = - 'bg-indigo-700 border border-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue'; + 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'; + styleClasses.dropdownSideButtonClasses += + ' bg-indigo-700 border-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue'; styleClasses.dropdownClasses = 'bg-indigo-600'; } diff --git a/src/components/Common/ListView/index.tsx b/src/components/Common/ListView/index.tsx index 5b3b0bc3..946f4f8f 100644 --- a/src/components/Common/ListView/index.tsx +++ b/src/components/Common/ListView/index.tsx @@ -38,7 +38,7 @@ const ListView: React.FC = ({
)}
    - {items?.map((title) => { + {items?.map((title, index) => { let titleCard: React.ReactNode; switch (title.mediaType) { @@ -90,7 +90,7 @@ const ListView: React.FC = ({ break; } - return
  • {titleCard}
  • ; + return
  • {titleCard}
  • ; })} {isLoading && !isReachingEnd && diff --git a/src/components/CompanyCard/index.tsx b/src/components/CompanyCard/index.tsx new file mode 100644 index 00000000..3e4c5938 --- /dev/null +++ b/src/components/CompanyCard/index.tsx @@ -0,0 +1,48 @@ +import Link from 'next/link'; +import React, { useState } from 'react'; + +interface CompanyCardProps { + name: string; + image: string; + url: string; +} + +const CompanyCard: React.FC = ({ image, url, name }) => { + const [isHovered, setHovered] = useState(false); + + return ( + + { + setHovered(true); + }} + onMouseLeave={() => setHovered(false)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + setHovered(true); + } + }} + role="link" + tabIndex={0} + > + {name} +
    + + + ); +}; + +export default CompanyCard; diff --git a/src/components/Discover/DiscoverMovieGenre/index.tsx b/src/components/Discover/DiscoverMovieGenre/index.tsx new file mode 100644 index 00000000..e340f4eb --- /dev/null +++ b/src/components/Discover/DiscoverMovieGenre/index.tsx @@ -0,0 +1,62 @@ +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 { useRouter } from 'next/router'; +import globalMessages from '../../../i18n/globalMessages'; +import useDiscover from '../../../hooks/useDiscover'; +import Error from '../../../pages/_error'; + +const messages = defineMessages({ + genreMovies: '{genre} Movies', +}); + +const DiscoverMovieGenre: React.FC = () => { + const router = useRouter(); + const intl = useIntl(); + + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + firstResultData, + } = useDiscover( + `/api/v1/discover/movies/genre/${router.query.genreId}` + ); + + if (error) { + return ; + } + + const title = isLoadingInitialData + ? intl.formatMessage(globalMessages.loading) + : intl.formatMessage(messages.genreMovies, { + genre: firstResultData?.genre.name, + }); + + return ( + <> + +
    +
    {title}
    +
    + 0) + } + isReachingEnd={isReachingEnd} + onScrollBottom={fetchMore} + /> + + ); +}; + +export default DiscoverMovieGenre; diff --git a/src/components/Discover/DiscoverMovieLanguage/index.tsx b/src/components/Discover/DiscoverMovieLanguage/index.tsx new file mode 100644 index 00000000..b1e19d05 --- /dev/null +++ b/src/components/Discover/DiscoverMovieLanguage/index.tsx @@ -0,0 +1,71 @@ +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 { useRouter } from 'next/router'; +import globalMessages from '../../../i18n/globalMessages'; +import useDiscover from '../../../hooks/useDiscover'; +import Error from '../../../pages/_error'; + +const messages = defineMessages({ + languageMovies: '{language} Movies', +}); + +const DiscoverMovieLanguage: React.FC = () => { + const router = useRouter(); + const intl = useIntl(); + + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover< + MovieResult, + { + originalLanguage: { + iso_639_1: string; + english_name: string; + name: string; + }; + } + >(`/api/v1/discover/movies/language/${router.query.language}`); + + if (error) { + return ; + } + + const title = isLoadingInitialData + ? intl.formatMessage(globalMessages.loading) + : intl.formatMessage(messages.languageMovies, { + language: intl.formatDisplayName(router.query.language as string, { + type: 'language', + fallback: 'none', + }), + }); + + return ( + <> + +
    +
    {title}
    +
    + 0) + } + isReachingEnd={isReachingEnd} + onScrollBottom={fetchMore} + /> + + ); +}; + +export default DiscoverMovieLanguage; diff --git a/src/components/Discover/DiscoverMovies.tsx b/src/components/Discover/DiscoverMovies.tsx index 4ebad143..cef4c623 100644 --- a/src/components/Discover/DiscoverMovies.tsx +++ b/src/components/Discover/DiscoverMovies.tsx @@ -1,80 +1,40 @@ -import React, { useContext } from 'react'; -import { useSWRInfinite } from 'swr'; +import React from 'react'; import type { MovieResult } from '../../../server/models/Search'; import ListView from '../Common/ListView'; -import { LanguageContext } from '../../context/LanguageContext'; -import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { defineMessages, useIntl } from 'react-intl'; import Header from '../Common/Header'; -import useSettings from '../../hooks/useSettings'; -import { MediaStatus } from '../../../server/constants/media'; import PageTitle from '../Common/PageTitle'; +import useDiscover from '../../hooks/useDiscover'; +import Error from '../../pages/_error'; const messages = defineMessages({ discovermovies: 'Popular Movies', }); -interface SearchResult { - page: number; - totalResults: number; - totalPages: number; - results: MovieResult[]; -} - const DiscoverMovies: React.FC = () => { const intl = useIntl(); - const settings = useSettings(); - const { locale } = useContext(LanguageContext); - const { data, error, size, setSize } = useSWRInfinite( - (pageIndex: number, previousPageData: SearchResult | null) => { - if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { - return null; - } - return `/api/v1/discover/movies?page=${pageIndex + 1}&language=${locale}`; - }, - { - initialSize: 3, - } - ); - - const isLoadingInitialData = !data && !error; - const isLoadingMore = - isLoadingInitialData || - (size > 0 && data && typeof data[size - 1] === 'undefined'); - - const fetchMore = () => { - setSize(size + 1); - }; + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover('/api/v1/discover/movies'); if (error) { - return
    {error}
    ; + return ; } - let titles = (data ?? []).reduce( - (a, v) => [...a, ...v.results], - [] as MovieResult[] - ); - - if (settings.currentSettings.hideAvailable) { - titles = titles.filter( - (i) => - (i.mediaType === 'movie' || i.mediaType === 'tv') && - i.mediaInfo?.status !== MediaStatus.AVAILABLE && - i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE - ); - } - - const isEmpty = !isLoadingInitialData && titles?.length === 0; - const isReachingEnd = - isEmpty || (data && data[data.length - 1]?.results.length < 20); + const title = intl.formatMessage(messages.discovermovies); return ( <> - +
    -
    - -
    +
    {title}
    { + const router = useRouter(); + const intl = useIntl(); + + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + firstResultData, + } = useDiscover( + `/api/v1/discover/tv/network/${router.query.networkId}` + ); + + if (error) { + return ; + } + + const title = isLoadingInitialData + ? intl.formatMessage(globalMessages.loading) + : intl.formatMessage(messages.networkSeries, { + network: firstResultData?.network.name, + }); + + return ( + <> + +
    +
    + {firstResultData?.network.logoPath ? ( +
    + {firstResultData.network.name} +
    + ) : ( + title + )} +
    +
    + 0) + } + isReachingEnd={isReachingEnd} + onScrollBottom={fetchMore} + /> + + ); +}; + +export default DiscoverTvNetwork; diff --git a/src/components/Discover/DiscoverStudio/index.tsx b/src/components/Discover/DiscoverStudio/index.tsx new file mode 100644 index 00000000..bc7e270d --- /dev/null +++ b/src/components/Discover/DiscoverStudio/index.tsx @@ -0,0 +1,75 @@ +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 { 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'; + +const messages = defineMessages({ + studioMovies: '{studio} Movies', +}); + +const DiscoverMovieStudio: React.FC = () => { + const router = useRouter(); + const intl = useIntl(); + + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + firstResultData, + } = useDiscover( + `/api/v1/discover/movies/studio/${router.query.studioId}` + ); + + if (error) { + return ; + } + + const title = isLoadingInitialData + ? intl.formatMessage(globalMessages.loading) + : intl.formatMessage(messages.studioMovies, { + studio: firstResultData?.studio.name, + }); + + return ( + <> + +
    +
    + {firstResultData?.studio.logoPath ? ( +
    + {firstResultData.studio.name} +
    + ) : ( + title + )} +
    +
    + 0) + } + isReachingEnd={isReachingEnd} + onScrollBottom={fetchMore} + /> + + ); +}; + +export default DiscoverMovieStudio; diff --git a/src/components/Discover/DiscoverTv.tsx b/src/components/Discover/DiscoverTv.tsx index d75cd943..60c29225 100644 --- a/src/components/Discover/DiscoverTv.tsx +++ b/src/components/Discover/DiscoverTv.tsx @@ -1,79 +1,40 @@ -import React, { useContext } from 'react'; -import { useSWRInfinite } from 'swr'; +import React from 'react'; import type { TvResult } from '../../../server/models/Search'; import ListView from '../Common/ListView'; -import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { LanguageContext } from '../../context/LanguageContext'; +import { defineMessages, useIntl } from 'react-intl'; import Header from '../Common/Header'; -import useSettings from '../../hooks/useSettings'; -import { MediaStatus } from '../../../server/constants/media'; import PageTitle from '../Common/PageTitle'; +import useDiscover from '../../hooks/useDiscover'; +import Error from '../../pages/_error'; const messages = defineMessages({ discovertv: 'Popular Series', }); -interface SearchResult { - page: number; - totalResults: number; - totalPages: number; - results: TvResult[]; -} - const DiscoverTv: React.FC = () => { const intl = useIntl(); - const settings = useSettings(); - const { locale } = useContext(LanguageContext); - const { data, error, size, setSize } = useSWRInfinite( - (pageIndex: number, previousPageData: SearchResult | null) => { - if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { - return null; - } - return `/api/v1/discover/tv?page=${pageIndex + 1}&language=${locale}`; - }, - { - initialSize: 3, - } - ); - - const isLoadingInitialData = !data && !error; - const isLoadingMore = - isLoadingInitialData || - (size > 0 && data && typeof data[size - 1] === 'undefined'); - - const fetchMore = () => { - setSize(size + 1); - }; + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover('/api/v1/discover/tv'); if (error) { - return
    {error}
    ; + return ; } - let titles = (data ?? []).reduce( - (a, v) => [...a, ...v.results], - [] as TvResult[] - ); - - if (settings.currentSettings.hideAvailable) { - titles = titles.filter( - (i) => - i.mediaInfo?.status !== MediaStatus.AVAILABLE && - i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE - ); - } - - const isEmpty = !isLoadingInitialData && titles?.length === 0; - const isReachingEnd = - isEmpty || (data && data[data.length - 1]?.results.length < 20); + const title = intl.formatMessage(messages.discovertv); return ( <> - +
    -
    - -
    +
    {title}
    { + const router = useRouter(); + const intl = useIntl(); + + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + firstResultData, + } = useDiscover( + `/api/v1/discover/tv/genre/${router.query.genreId}` + ); + + if (error) { + return ; + } + + const title = isLoadingInitialData + ? intl.formatMessage(globalMessages.loading) + : intl.formatMessage(messages.genreSeries, { + genre: firstResultData?.genre.name, + }); + + return ( + <> + +
    +
    {title}
    +
    + 0) + } + isReachingEnd={isReachingEnd} + onScrollBottom={fetchMore} + /> + + ); +}; + +export default DiscoverTvGenre; diff --git a/src/components/Discover/DiscoverTvLanguage/index.tsx b/src/components/Discover/DiscoverTvLanguage/index.tsx new file mode 100644 index 00000000..ed0873f9 --- /dev/null +++ b/src/components/Discover/DiscoverTvLanguage/index.tsx @@ -0,0 +1,71 @@ +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 { useRouter } from 'next/router'; +import globalMessages from '../../../i18n/globalMessages'; +import useDiscover from '../../../hooks/useDiscover'; +import Error from '../../../pages/_error'; + +const messages = defineMessages({ + languageSeries: '{language} Series', +}); + +const DiscoverTvLanguage: React.FC = () => { + const router = useRouter(); + const intl = useIntl(); + + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover< + TvResult, + { + originalLanguage: { + iso_639_1: string; + english_name: string; + name: string; + }; + } + >(`/api/v1/discover/tv/language/${router.query.language}`); + + if (error) { + return ; + } + + const title = isLoadingInitialData + ? intl.formatMessage(globalMessages.loading) + : intl.formatMessage(messages.languageSeries, { + language: intl.formatDisplayName(router.query.language as string, { + type: 'language', + fallback: 'none', + }), + }); + + return ( + <> + +
    +
    {title}
    +
    + 0) + } + isReachingEnd={isReachingEnd} + onScrollBottom={fetchMore} + /> + + ); +}; + +export default DiscoverTvLanguage; diff --git a/src/components/Discover/DiscoverTvUpcoming.tsx b/src/components/Discover/DiscoverTvUpcoming.tsx index 6e08c29d..5b59f26a 100644 --- a/src/components/Discover/DiscoverTvUpcoming.tsx +++ b/src/components/Discover/DiscoverTvUpcoming.tsx @@ -1,81 +1,38 @@ -import React, { useContext } from 'react'; -import { useSWRInfinite } from 'swr'; +import React from 'react'; import type { TvResult } from '../../../server/models/Search'; import ListView from '../Common/ListView'; -import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { LanguageContext } from '../../context/LanguageContext'; +import { defineMessages, useIntl } from 'react-intl'; import Header from '../Common/Header'; -import useSettings from '../../hooks/useSettings'; -import { MediaStatus } from '../../../server/constants/media'; import PageTitle from '../Common/PageTitle'; +import useDiscover from '../../hooks/useDiscover'; +import Error from '../../pages/_error'; const messages = defineMessages({ upcomingtv: 'Upcoming Series', }); -interface SearchResult { - page: number; - totalResults: number; - totalPages: number; - results: TvResult[]; -} - const DiscoverTvUpcoming: React.FC = () => { const intl = useIntl(); - const settings = useSettings(); - const { locale } = useContext(LanguageContext); - const { data, error, size, setSize } = useSWRInfinite( - (pageIndex: number, previousPageData: SearchResult | null) => { - if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { - return null; - } - return `/api/v1/discover/tv/upcoming?page=${ - pageIndex + 1 - }&language=${locale}`; - }, - { - initialSize: 3, - } - ); - - const isLoadingInitialData = !data && !error; - const isLoadingMore = - isLoadingInitialData || - (size > 0 && data && typeof data[size - 1] === 'undefined'); - - const fetchMore = () => { - setSize(size + 1); - }; + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover('/api/v1/discover/tv/upcoming'); if (error) { - return
    {error}
    ; + return ; } - let titles = (data ?? []).reduce( - (a, v) => [...a, ...v.results], - [] as TvResult[] - ); - - if (settings.currentSettings.hideAvailable) { - titles = titles.filter( - (i) => - i.mediaInfo?.status !== MediaStatus.AVAILABLE && - i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE - ); - } - - const isEmpty = !isLoadingInitialData && titles?.length === 0; - const isReachingEnd = - isEmpty || (data && data[data.length - 1]?.results.length < 20); - return ( <>
    -
    - -
    +
    {intl.formatMessage(messages.upcomingtv)}
    { + const intl = useIntl(); + + return ( + <> +
    +
    + {intl.formatMessage(messages.networks)} +
    +
    + ( + + ))} + emptyMessage="" + /> + + ); +}; + +export default NetworkSlider; diff --git a/src/components/Discover/StudioSlider/index.tsx b/src/components/Discover/StudioSlider/index.tsx new file mode 100644 index 00000000..69f7b5d7 --- /dev/null +++ b/src/components/Discover/StudioSlider/index.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import CompanyCard from '../../CompanyCard'; +import Slider from '../../Slider'; + +const messages = defineMessages({ + studios: 'Studios', +}); + +interface Studio { + name: string; + image: string; + url: string; +} + +const studios: Studio[] = [ + { + name: 'Disney', + image: + 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/wdrCwmRnLFJhEoH8GSfymY85KHT.png', + url: '/discover/movies/studio/2', + }, + { + name: '20th Century Fox', + image: + 'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/qZCc1lty5FzX30aOCVRBLzaVmcp.png', + url: '/discover/movies/studio/25', + }, + { + name: 'Sony Pictures', + image: + 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/GagSvqWlyPdkFHMfQ3pNq6ix9P.png', + url: '/discover/movies/studio/34', + }, + { + name: 'Warner Bros. Pictures', + image: + 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ky0xOc5OrhzkZ1N6KyUxacfQsCk.png', + url: '/discover/movies/studio/174', + }, + { + name: 'Universal', + image: + 'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/8lvHyhjr8oUKOOy2dKXoALWKdp0.png', + url: '/discover/movies/studio/33', + }, + { + name: 'Paramount', + image: + 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/fycMZt242LVjagMByZOLUGbCvv3.png', + url: '/discover/movies/studio/4', + }, + { + name: 'Pixar', + image: + 'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/1TjvGVDMYsj6JBxOAkUHpPEwLf7.png', + url: '/discover/movies/studio/3', + }, + { + name: 'Dreamworks', + image: + 'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/kP7t6RwGz2AvvTkvnI1uteEwHet.png', + url: '/discover/movies/studio/521', + }, + { + name: 'Marvel Studios', + image: + 'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/hUzeosd33nzE5MCNsZxCGEKTXaQ.png', + url: '/discover/movies/studio/420', + }, + { + name: 'DC', + image: + 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/2Tc1P3Ac8M479naPp1kYT3izLS5.png', + url: '/discover/movies/studio/9993', + }, +]; + +const StudioSlider: React.FC = () => { + const intl = useIntl(); + + return ( + <> +
    +
    + {intl.formatMessage(messages.studios)} +
    +
    + ( + + ))} + emptyMessage="" + /> + + ); +}; + +export default StudioSlider; diff --git a/src/components/Discover/Trending.tsx b/src/components/Discover/Trending.tsx index 75da4a41..c0f2e222 100644 --- a/src/components/Discover/Trending.tsx +++ b/src/components/Discover/Trending.tsx @@ -1,86 +1,43 @@ -import React, { useContext } from 'react'; -import { useSWRInfinite } from 'swr'; +import React from 'react'; import type { MovieResult, TvResult, PersonResult, } from '../../../server/models/Search'; import ListView from '../Common/ListView'; -import { LanguageContext } from '../../context/LanguageContext'; -import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { defineMessages, useIntl } from 'react-intl'; import Header from '../Common/Header'; -import useSettings from '../../hooks/useSettings'; -import { MediaStatus } from '../../../server/constants/media'; import PageTitle from '../Common/PageTitle'; +import useDiscover from '../../hooks/useDiscover'; +import Error from '../../pages/_error'; const messages = defineMessages({ trending: 'Trending', }); -interface SearchResult { - page: number; - totalResults: number; - totalPages: number; - results: (MovieResult | TvResult | PersonResult)[]; -} - const Trending: React.FC = () => { const intl = useIntl(); - const settings = useSettings(); - const { locale } = useContext(LanguageContext); - const { data, error, size, setSize } = useSWRInfinite( - (pageIndex: number, previousPageData: SearchResult | null) => { - if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { - return null; - } - - return `/api/v1/discover/trending?page=${ - pageIndex + 1 - }&language=${locale}`; - }, - { - initialSize: 3, - } + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover( + '/api/v1/discover/trending' ); - const isLoadingInitialData = !data && !error; - const isLoadingMore = - isLoadingInitialData || - (size > 0 && data && typeof data[size - 1] === 'undefined'); - - const fetchMore = () => { - setSize(size + 1); - }; - if (error) { - return
    {error}
    ; + return ; } - let titles = (data ?? []).reduce( - (a, v) => [...a, ...v.results], - [] as (MovieResult | TvResult | PersonResult)[] - ); - - if (settings.currentSettings.hideAvailable) { - titles = titles.filter( - (i) => - (i.mediaType === 'movie' || i.mediaType === 'tv') && - i.mediaInfo?.status !== MediaStatus.AVAILABLE && - i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE - ); - } - - const isEmpty = !isLoadingInitialData && titles?.length === 0; - const isReachingEnd = - isEmpty || (data && data[data.length - 1]?.results.length < 20); - return ( <>
    -
    - -
    +
    {intl.formatMessage(messages.trending)}
    { const intl = useIntl(); - const settings = useSettings(); - const { locale } = useContext(LanguageContext); - const { data, error, size, setSize } = useSWRInfinite( - (pageIndex: number, previousPageData: SearchResult | null) => { - if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { - return null; - } - return `/api/v1/discover/movies/upcoming?page=${ - pageIndex + 1 - }&language=${locale}`; - }, - { - initialSize: 3, - } - ); - - const isLoadingInitialData = !data && !error; - const isLoadingMore = - isLoadingInitialData || - (size > 0 && data && typeof data[size - 1] === 'undefined'); - - const fetchMore = () => { - setSize(size + 1); - }; + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover('/api/v1/discover/movies/upcoming'); if (error) { - return
    {error}
    ; + return ; } - let titles = (data ?? []).reduce( - (a, v) => [...a, ...v.results], - [] as MovieResult[] - ); - - if (settings.currentSettings.hideAvailable) { - titles = titles.filter( - (i) => - i.mediaInfo?.status !== MediaStatus.AVAILABLE && - i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE - ); - } - - const isEmpty = !isLoadingInitialData && titles?.length === 0; - const isReachingEnd = - isEmpty || (data && data[data.length - 1]?.results.length < 20); - return ( <>
    -
    - -
    +
    {intl.formatMessage(messages.upcomingmovies)}
    { return ( <> -
    -
    -
    - - - -
    +
    +
    + {intl.formatMessage(messages.recentlyAdded)}
    { /> ))} /> -
    - + { linkUrl="/discover/movies/upcoming" url="/api/v1/discover/movies/upcoming" /> + { url="/api/v1/discover/tv/upcoming" linkUrl="/discover/tv/upcoming" /> + ); }; diff --git a/src/components/ExternalLinkBlock/index.tsx b/src/components/ExternalLinkBlock/index.tsx index 680cf1ee..6edbea9d 100644 --- a/src/components/ExternalLinkBlock/index.tsx +++ b/src/components/ExternalLinkBlock/index.tsx @@ -24,11 +24,11 @@ const ExternalLinkBlock: React.FC = ({ plexUrl, }) => { return ( -
    +
    {plexUrl && ( @@ -38,7 +38,7 @@ const ExternalLinkBlock: React.FC = ({ {tmdbId && ( @@ -48,7 +48,7 @@ const ExternalLinkBlock: React.FC = ({ {tvdbId && mediaType === MediaType.TV && ( @@ -58,7 +58,7 @@ const ExternalLinkBlock: React.FC = ({ {imdbId && ( @@ -68,7 +68,7 @@ const ExternalLinkBlock: React.FC = ({ {rtUrl && ( diff --git a/src/components/Layout/LanguagePicker/index.tsx b/src/components/Layout/LanguagePicker/index.tsx index 8bb1be32..77377ff9 100644 --- a/src/components/Layout/LanguagePicker/index.tsx +++ b/src/components/Layout/LanguagePicker/index.tsx @@ -89,7 +89,9 @@ const LanguagePicker: React.FC = () => {
    {
    -
    +
    diff --git a/src/components/LoadingBar/index.tsx b/src/components/LoadingBar/index.tsx new file mode 100644 index 00000000..918fe134 --- /dev/null +++ b/src/components/LoadingBar/index.tsx @@ -0,0 +1,73 @@ +import { NProgress } from '@tanem/react-nprogress'; +import React, { useEffect, useState } from 'react'; +import ReactDOM from 'react-dom'; +import { useRouter } from 'next/router'; + +interface BarProps { + progress: number; + isFinished: boolean; +} + +const Bar = ({ progress, isFinished }: BarProps) => { + return ( +
    +
    +
    + ); +}; + +const NProgressBar = ({ loading }: { loading: boolean }) => ( + + {({ isFinished, progress }) => ( + + )} + +); + +const MemoizedNProgress = React.memo(NProgressBar); + +const LoadingBar = (): React.ReactPortal | null => { + const [mounted, setMounted] = useState(false); + const [loading, setLoading] = useState(false); + const router = useRouter(); + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + const handleLoading = () => { + setLoading(true); + }; + const handleFinishedLoading = () => { + setLoading(false); + }; + router.events.on('routeChangeStart', handleLoading); + router.events.on('routeChangeComplete', handleFinishedLoading); + router.events.on('routeChangeError', handleFinishedLoading); + + return () => { + router.events.off('routeChangeStart', handleLoading); + router.events.off('routeChangeComplete', handleFinishedLoading); + router.events.off('routeChangeError', handleFinishedLoading); + }; + }, [router]); + + return mounted + ? ReactDOM.createPortal( + , + document.body + ) + : null; +}; + +export default LoadingBar; diff --git a/src/components/Login/LocalLogin.tsx b/src/components/Login/LocalLogin.tsx index c870acbf..365cb334 100644 --- a/src/components/Login/LocalLogin.tsx +++ b/src/components/Login/LocalLogin.tsx @@ -63,7 +63,7 @@ const LocalLogin: React.FC = ({ revalidate }) => { {intl.formatMessage(messages.email)}
    -
    +
    = ({ revalidate }) => { {intl.formatMessage(messages.password)}
    -
    +
    = ({ url, posters }) => { >
    diff --git a/src/components/MediaSlider/index.tsx b/src/components/MediaSlider/index.tsx index 14d832ee..c46e16bc 100644 --- a/src/components/MediaSlider/index.tsx +++ b/src/components/MediaSlider/index.tsx @@ -135,34 +135,32 @@ const MediaSlider: React.FC = ({ return ( <> -
    - { - const settings = useSettings(); const intl = useIntl(); const router = useRouter(); const { locale } = useContext(LanguageContext); const { data: movieData, error: movieError } = useSWR( `/api/v1/movie/${router.query.movieId}?language=${locale}` ); - const { data, error, size, setSize } = useSWRInfinite( - (pageIndex: number, previousPageData: SearchResult | null) => { - if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { - return null; - } - - return `/api/v1/movie/${router.query.movieId}/recommendations?page=${ - pageIndex + 1 - }&language=${locale}`; - }, - { - initialSize: 3, - } + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover( + `/api/v1/movie/${router.query.movieId}/recommendations` ); - const isLoadingInitialData = !data && !error; - const isLoadingMore = - isLoadingInitialData || - (size > 0 && data && typeof data[size - 1] === 'undefined'); - - const fetchMore = () => { - setSize(size + 1); - }; - if (error) { - return
    {error}
    ; + return ; } - let titles = (data ?? []).reduce( - (a, v) => [...a, ...v.results], - [] as MovieResult[] - ); - - if (settings.currentSettings.hideAvailable) { - titles = titles.filter( - (i) => - i.mediaInfo?.status !== MediaStatus.AVAILABLE && - i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE - ); - } - - const isEmpty = !isLoadingInitialData && titles?.length === 0; - const isReachingEnd = - isEmpty || (data && data[data.length - 1]?.results.length < 20); - return ( <> { - const settings = useSettings(); const router = useRouter(); const intl = useIntl(); const { locale } = useContext(LanguageContext); const { data: movieData, error: movieError } = useSWR( `/api/v1/movie/${router.query.movieId}?language=${locale}` ); - const { data, error, size, setSize } = useSWRInfinite( - (pageIndex: number, previousPageData: SearchResult | null) => { - if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { - return null; - } - - return `/api/v1/movie/${router.query.movieId}/similar?page=${ - pageIndex + 1 - }&language=${locale}`; - }, - { - initialSize: 3, - } - ); - - const isLoadingInitialData = !data && !error; - const isLoadingMore = - isLoadingInitialData || - (size > 0 && data && typeof data[size - 1] === 'undefined'); - - const fetchMore = () => { - setSize(size + 1); - }; + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover(`/api/v1/movie/${router.query.movieId}/similar`); if (error) { - return
    {error}
    ; + return ; } - let titles = (data ?? []).reduce( - (a, v) => [...a, ...v.results], - [] as MovieResult[] - ); - - if (settings.currentSettings.hideAvailable) { - titles = titles.filter( - (i) => - i.mediaInfo?.status !== MediaStatus.AVAILABLE && - i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE - ); - } - - const isEmpty = !isLoadingInitialData && titles?.length === 0; - const isReachingEnd = - isEmpty || (data && data[data.length - 1]?.results.length < 20); - return ( <> = ({ movie }) => { } if (data.genres.length) { - movieAttributes.push(data.genres.map((g) => g.name).join(', ')); + movieAttributes.push( + data.genres + .map((g) => ( + +
    {g.name} + + )) + .reduce((prev, curr) => ( + <> + {prev}, {curr} + + )) + ); } return (
    = ({ movie }) => {
    )} -
    -
    - -
    -
    -
    - - 0} - plexUrl={data.mediaInfo?.plexUrl} - /> - +
    + +
    +
    + 0} + plexUrl={data.mediaInfo?.plexUrl} + /> {settings.currentSettings.movie4kEnabled && hasPermission( [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], @@ -399,25 +403,25 @@ const MovieDetails: React.FC = ({ movie }) => { type: 'or', } ) && ( - - 0 - } - plexUrl4k={data.mediaInfo?.plexUrl4k} - /> - + 0 + } + plexUrl4k={data.mediaInfo?.plexUrl4k} + /> )}
    -

    +

    {data.title}{' '} {data.releaseDate && ( - ({data.releaseDate.slice(0, 4)}) + + ({data.releaseDate.slice(0, 4)}) + )}

    - + {movieAttributes.length > 0 && movieAttributes .map((t, k) => {t}) @@ -428,27 +432,23 @@ const MovieDetails: React.FC = ({ movie }) => { ))}
    -
    -
    - -
    -
    - revalidate()} - /> -
    +
    + + revalidate()} + /> {hasPermission(Permission.MANAGE_REQUESTS) && (
    -
    -
    -

    - {intl.formatMessage(messages.overview)} -

    -

    +

    +
    +
    {data.tagline}
    +

    {intl.formatMessage(messages.overview)}

    +

    {data.overview ? data.overview : intl.formatMessage(messages.overviewunavailable)}

    -
      +
        {sortedCrew.slice(0, 6).map((person) => ( -
      • - {person.job} +
      • + {person.job} - - {person.name} - + {person.name}
      • ))} @@ -520,7 +514,7 @@ const MovieDetails: React.FC = ({ movie }) => {
    )}
    -
    +
    {data.collection && (
    @@ -542,80 +536,65 @@ const MovieDetails: React.FC = ({ movie }) => {
    )} -
    +
    {(!!data.voteCount || (ratingData?.criticsRating && !!ratingData?.criticsScore) || (ratingData?.audienceRating && !!ratingData?.audienceScore)) && ( -
    +
    {ratingData?.criticsRating && !!ratingData?.criticsScore && ( <> - + {ratingData.criticsRating === 'Rotten' ? ( ) : ( )} - - {ratingData.criticsScore}% )} {ratingData?.audienceRating && !!ratingData?.audienceScore && ( <> - + {ratingData.audienceRating === 'Spilled' ? ( ) : ( )} - - {ratingData.audienceScore}% )} {!!data.voteCount && ( <> - + - - {data.voteAverage}/10 )}
    )} +
    + {intl.formatMessage(messages.status)} + {data.status} +
    {data.releaseDate && ( -
    - - {intl.formatMessage(messages.releasedate)} - - - +
    + {intl.formatMessage(messages.releasedate)} + + {intl.formatDate(data.releaseDate, { + year: 'numeric', + month: 'long', + day: 'numeric', + })}
    )} -
    - - {intl.formatMessage(messages.status)} - - - {data.status} - -
    {data.revenue > 0 && ( -
    - - {intl.formatMessage(messages.revenue)} - - +
    + {intl.formatMessage(messages.revenue)} + = ({ movie }) => {
    )} {data.budget > 0 && ( -
    - - {intl.formatMessage(messages.budget)} - - +
    + {intl.formatMessage(messages.budget)} + = ({ movie }) => {
    )} - {data.spokenLanguages.some( - (lng) => lng.iso_639_1 === data.originalLanguage - ) && ( -
    - - {intl.formatMessage(messages.originallanguage)} - - - { - data.spokenLanguages.find( - (lng) => lng.iso_639_1 === data.originalLanguage - )?.name - } + {data.originalLanguage && ( + )} - {data.productionCompanies[0] && ( -
    - - {intl.formatMessage(messages.studio)} + {data.productionCompanies.length > 0 && ( +
    + + {intl.formatMessage(messages.studio, { + studioCount: data.productionCompanies.length, + })} - - {data.productionCompanies[0]?.name} + + {data.productionCompanies.map((s) => { + return ( + + {s.name} + + ); + })}
    )} -
    -
    - +
    + +
    {data.credits.cast.length > 0 && ( <> -
    - + = ({ description: intl.formatMessage(messages.mediarequestedDescription), value: Notification.MEDIA_PENDING, }, + { + id: 'media-auto-approved', + name: intl.formatMessage(messages.mediaAutoApproved), + description: intl.formatMessage(messages.mediaAutoApprovedDescription), + value: Notification.MEDIA_AUTO_APPROVED, + }, { id: 'media-approved', name: intl.formatMessage(messages.mediaapproved), diff --git a/src/components/PermissionOption/index.tsx b/src/components/PermissionOption/index.tsx index 37c807e8..314c9944 100644 --- a/src/components/PermissionOption/index.tsx +++ b/src/components/PermissionOption/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { hasPermission } from '../../../server/lib/permissions'; import { Permission, User } from '../../hooks/useUser'; +import useSettings from '../../hooks/useSettings'; export interface PermissionItem { id: string; @@ -33,6 +34,8 @@ const PermissionOption: React.FC = ({ onUpdate, parent, }) => { + const settings = useSettings(); + const autoApprovePermissions = [ Permission.AUTO_APPROVE, Permission.AUTO_APPROVE_MOVIE, @@ -42,34 +45,70 @@ const PermissionOption: React.FC = ({ Permission.AUTO_APPROVE_4K_TV, ]; + let disabled = false; + let checked = hasPermission(option.permission, currentPermission); + + if ( + // Permissions for user ID 1 (Plex server owner) cannot be changed + (currentUser && currentUser.id === 1) || + // Admin permission automatically bypasses/grants all other permissions + (option.permission !== Permission.ADMIN && + hasPermission(Permission.ADMIN, currentPermission)) || + // Manage Requests permission automatically grants all Auto-Approve permissions + (autoApprovePermissions.includes(option.permission) && + hasPermission(Permission.MANAGE_REQUESTS, currentPermission)) || + // Selecting a parent permission automatically selects all children + (!!parent?.permission && + hasPermission(parent.permission, currentPermission)) + ) { + disabled = true; + checked = true; + } + + 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) + ) { + disabled = true; + } + + if ( + // Some permissions are dependent on others; check requirements are fulfilled + (option.requires && + !option.requires.every((requirement) => + hasPermission(requirement.permissions, currentPermission, { + type: requirement.type ?? 'and', + }) + )) || + // Request 4K and Auto-Approve 4K require both 4K movie & 4K series requests to be enabled + ((option.permission === Permission.REQUEST_4K || + option.permission === Permission.AUTO_APPROVE_4K) && + (!settings.currentSettings.movie4kEnabled || + !settings.currentSettings.series4kEnabled)) || + // Request 4K Movie and Auto-Approve 4K Movie require 4K movie requests to be enabled + ((option.permission === Permission.REQUEST_4K_MOVIE || + option.permission === Permission.AUTO_APPROVE_4K_MOVIE) && + !settings.currentSettings.movie4kEnabled) || + // Request 4K Series and Auto-Approve 4K Series require 4K series requests to be enabled + ((option.permission === Permission.REQUEST_4K_TV || + option.permission === Permission.AUTO_APPROVE_4K_TV) && + !settings.currentSettings.series4kEnabled) + ) { + disabled = true; + checked = false; + } + return ( <>
    - hasPermission(requirement.permissions, currentPermission, { - type: requirement.type ?? 'and', - }) - )) - ? 'opacity-50' - : '' + disabled ? 'opacity-50' : '' }`} >
    @@ -77,30 +116,7 @@ const PermissionOption: React.FC = ({ id={option.id} name="permissions" type="checkbox" - disabled={ - (currentUser && currentUser.id === 1) || - (option.permission !== Permission.ADMIN && - hasPermission(Permission.ADMIN, currentPermission)) || - (autoApprovePermissions.includes(option.permission) && - hasPermission(Permission.MANAGE_REQUESTS, currentPermission)) || - (!!parent?.permission && - hasPermission(parent.permission, currentPermission)) || - (actingUser && - !hasPermission(Permission.ADMIN, actingUser.permissions) && - option.permission === Permission.ADMIN) || - (actingUser && - !hasPermission( - Permission.MANAGE_SETTINGS, - actingUser.permissions - ) && - option.permission === Permission.MANAGE_SETTINGS) || - (option.requires && - !option.requires.every((requirement) => - hasPermission(requirement.permissions, currentPermission, { - type: requirement.type ?? 'and', - }) - )) - } + disabled={disabled} onChange={() => { onUpdate( hasPermission(option.permission, currentPermission) @@ -108,26 +124,11 @@ const PermissionOption: React.FC = ({ : currentPermission + option.permission ); }} - checked={ - (hasPermission(option.permission, currentPermission) || - (!!parent?.permission && - hasPermission(parent.permission, currentPermission)) || - (autoApprovePermissions.includes(option.permission) && - hasPermission( - Permission.MANAGE_REQUESTS, - currentPermission - ))) && - (!option.requires || - option.requires.every((requirement) => - hasPermission(requirement.permissions, currentPermission, { - type: requirement.type ?? 'and', - }) - )) - } + checked={checked} />
    -