Merge branch 'develop'
This commit is contained in:
@@ -312,6 +312,70 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"code"
|
"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": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
||||||
|
|||||||
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
name: Lint & Test Build
|
name: Lint & Test Build
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
container: node:12.18-alpine
|
container: node:14.16-alpine
|
||||||
steps:
|
steps:
|
||||||
- name: checkout
|
- name: checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
@@ -24,6 +24,7 @@ jobs:
|
|||||||
run: yarn lint
|
run: yarn lint
|
||||||
- name: build
|
- name: build
|
||||||
run: yarn build
|
run: yarn build
|
||||||
|
|
||||||
build_and_push:
|
build_and_push:
|
||||||
name: Build & Publish to Docker Hub
|
name: Build & Publish to Docker Hub
|
||||||
needs: test
|
needs: test
|
||||||
@@ -59,6 +60,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
COMMIT_TAG=${{ github.sha }}
|
COMMIT_TAG=${{ github.sha }}
|
||||||
@@ -68,7 +70,15 @@ jobs:
|
|||||||
ghcr.io/sct/overseerr:develop
|
ghcr.io/sct/overseerr:develop
|
||||||
ghcr.io/sct/overseerr:${{ github.sha }}
|
ghcr.io/sct/overseerr:${{ github.sha }}
|
||||||
cache-from: type=local,src=/tmp/.buildx-cache
|
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:
|
discord:
|
||||||
name: Send Discord Notification
|
name: Send Discord Notification
|
||||||
needs: build_and_push
|
needs: build_and_push
|
||||||
@@ -76,8 +86,7 @@ jobs:
|
|||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
steps:
|
steps:
|
||||||
- name: Get Build Job Status
|
- 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
|
- name: Combine Job Status
|
||||||
id: status
|
id: status
|
||||||
run: |
|
run: |
|
||||||
@@ -87,7 +96,6 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Post Status to Discord
|
- name: Post Status to Discord
|
||||||
uses: sarisia/actions-status-discord@v1
|
uses: sarisia/actions-status-discord@v1
|
||||||
with:
|
with:
|
||||||
|
|||||||
23
.github/workflows/release.yml
vendored
23
.github/workflows/release.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
name: Lint & Test Build
|
name: Lint & Test Build
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
container: node:12.18-alpine
|
container: node:14.16-alpine
|
||||||
steps:
|
steps:
|
||||||
- name: checkout
|
- name: checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
@@ -31,9 +31,24 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v2
|
||||||
with:
|
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
|
- name: Install dependencies
|
||||||
run: yarn
|
run: yarn
|
||||||
- name: Release
|
- name: Release
|
||||||
@@ -108,7 +123,7 @@ jobs:
|
|||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
steps:
|
steps:
|
||||||
- name: Get Build Job Status
|
- 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
|
- name: Combine Job Status
|
||||||
id: status
|
id: status
|
||||||
|
|||||||
2
.github/workflows/snap.yaml
vendored
2
.github/workflows/snap.yaml
vendored
@@ -90,7 +90,7 @@ jobs:
|
|||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
steps:
|
steps:
|
||||||
- name: Get Build Job Status
|
- 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
|
- name: Combine Job Status
|
||||||
id: status
|
id: status
|
||||||
|
|||||||
@@ -1,56 +1,63 @@
|
|||||||
# Contributing to Overseerr
|
# 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
|
## Development
|
||||||
|
|
||||||
### Tools Required
|
### 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.)
|
- HTML/Typescript/Javascript editor
|
||||||
- [NodeJS](https://nodejs.org/en/download/) (Node 12.x.x or higher)
|
- [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/)
|
- [Yarn](https://yarnpkg.com/)
|
||||||
- [Git](https://git-scm.com/downloads)
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
|
||||||
### Getting Started
|
### 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
|
```bash
|
||||||
git clone https://github.com/YOUR_USERNAME/overseerr.git
|
git clone https://github.com/YOUR_USERNAME/overseerr.git
|
||||||
cd overseerr/
|
cd overseerr/
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Add the remote upstream.
|
2. Add the remote `upstream`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git remote add upstream https://github.com/sct/overseerr.git
|
git remote add upstream https://github.com/sct/overseerr.git
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Create a new branch
|
3. Create a new branch:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git checkout -b BRANCH_NAME develop
|
git checkout -b BRANCH_NAME develop
|
||||||
```
|
```
|
||||||
|
|
||||||
- It is recommended to name the branch something relevant to the feature or fix you are working on.
|
- It is recommended to give your branch a meaningful name, relevant to the feature or fix you are working on.
|
||||||
- An example of this would be `fix-title-cards` or `feature-new-system`.
|
- Good examples:
|
||||||
- Bad examples would be `patch` or `bug`.
|
- `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
|
```bash
|
||||||
yarn
|
yarn
|
||||||
yarn dev
|
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`:
|
|
||||||
|
|
||||||
|
- 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
|
```bash
|
||||||
git fetch upstream
|
git fetch upstream
|
||||||
git rebase upstream/develop
|
git rebase upstream/develop
|
||||||
@@ -59,22 +66,22 @@ All help is welcome and greatly appreciated. If you would like to contribute to
|
|||||||
|
|
||||||
### Contributing Code
|
### 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/)
|
- 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.
|
- It is okay to squash your pull request down into a single commit that fits this standard.
|
||||||
- PRs with commits not following this standard will not be merged.
|
- Pull requests with commits not following this standard will **not** be merged.
|
||||||
- Please make meaningful commits, or squash them.
|
- Please make meaningful commits, or squash them.
|
||||||
- Always rebase your commit to the latest `develop` branch. Do not merge `develop` into your 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. It will not be merged unless its rebased off the latest `develop` 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.
|
- 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.
|
- 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.
|
- 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 in [GitHub Discussions](https://github.com/sct/overseerr/discussions) or in our [Discord](https://discord.gg/PkCWJSeCk7).
|
- 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 PRs opened to `master` will be closed.
|
- Only open pull requests to `develop`, never `master`! Any pull requests opened to `master` will be closed.
|
||||||
|
|
||||||
### UI Text Style
|
### 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.
|
1. Be concise and clear, and use as few words as possible to make your point.
|
||||||
2. Use the Oxford comma where appropriate.
|
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
|
## 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).
|
||||||
|
|
||||||
<a href="https://hosted.weblate.org/engage/overseerr/"><img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/multi-auto.svg" alt="Translation status" /></a>
|
<a href="https://hosted.weblate.org/engage/overseerr/"><img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/multi-auto.svg" alt="Translation status" /></a>
|
||||||
|
|
||||||
|
|||||||
20
Dockerfile
20
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
|
ARG COMMIT_TAG
|
||||||
ENV COMMIT_TAG=${COMMIT_TAG}
|
ENV COMMIT_TAG=${COMMIT_TAG}
|
||||||
@@ -6,7 +9,13 @@ ENV COMMIT_TAG=${COMMIT_TAG}
|
|||||||
COPY . /app
|
COPY . /app
|
||||||
WORKDIR /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
|
yarn build
|
||||||
|
|
||||||
# remove development dependencies
|
# remove development dependencies
|
||||||
@@ -20,14 +29,15 @@ RUN touch config/DOCKER
|
|||||||
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
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
|
||||||
COPY --from=BUILD_IMAGE /app /app
|
COPY --from=BUILD_IMAGE /app /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
CMD yarn start
|
ENTRYPOINT [ "/sbin/tini", "--" ]
|
||||||
|
CMD [ "yarn", "start" ]
|
||||||
|
|
||||||
EXPOSE 5055
|
EXPOSE 5055
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:12.18-alpine
|
FROM node:14.16-alpine
|
||||||
|
|
||||||
COPY . /app
|
COPY . /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -12,7 +12,7 @@
|
|||||||
<a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a>
|
<a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a>
|
||||||
<a href="https://github.com/sct/overseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr"></a>
|
<a href="https://github.com/sct/overseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr"></a>
|
||||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-33-orange.svg"/></a>
|
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-40-orange.svg"/></a>
|
||||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -47,6 +47,7 @@ Currently, Overseerr is primarily distributed as Docker images. If you have Dock
|
|||||||
|
|
||||||
```
|
```
|
||||||
docker run -d \
|
docker run -d \
|
||||||
|
--name overseerr \
|
||||||
-e LOG_LEVEL=info \
|
-e LOG_LEVEL=info \
|
||||||
-e TZ=Asia/Tokyo \
|
-e TZ=Asia/Tokyo \
|
||||||
-p 5055:5055 \
|
-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
|
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! ⚠️
|
⚠️ 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
|
|||||||
<td align="center"><a href="https://hmnd.io"><img src="https://avatars.githubusercontent.com/u/12853597?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=hmnd" title="Code">💻</a></td>
|
<td align="center"><a href="https://hmnd.io"><img src="https://avatars.githubusercontent.com/u/12853597?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=hmnd" title="Code">💻</a></td>
|
||||||
<td align="center"><a href="https://www.douglas-parker.com"><img src="https://avatars.githubusercontent.com/u/18235822?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Douglas Parker</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=douglasparker" title="Documentation">📖</a></td>
|
<td align="center"><a href="https://www.douglas-parker.com"><img src="https://avatars.githubusercontent.com/u/18235822?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Douglas Parker</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=douglasparker" title="Documentation">📖</a></td>
|
||||||
<td align="center"><a href="https://github.com/dancarter"><img src="https://avatars.githubusercontent.com/u/4387516?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Daniel Carter</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=dancarter" title="Code">💻</a></td>
|
<td align="center"><a href="https://github.com/dancarter"><img src="https://avatars.githubusercontent.com/u/4387516?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Daniel Carter</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=dancarter" title="Code">💻</a></td>
|
||||||
|
<td align="center"><a href="https://nuro.dev"><img src="https://avatars.githubusercontent.com/u/4991309?v=4?s=100" width="100px;" alt=""/><br /><sub><b>nuro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=NuroDev" title="Documentation">📖</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/onedr0p"><img src="https://avatars.githubusercontent.com/u/213795?v=4?s=100" width="100px;" alt=""/><br /><sub><b>ᗪєνιη ᗷυнʟ</b></sub></a><br /><a href="#infra-onedr0p" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><a href="https://github.com/JonnyWong16"><img src="https://avatars.githubusercontent.com/u/9099342?v=4?s=100" width="100px;" alt=""/><br /><sub><b>JonnyWong16</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JonnyWong16" title="Documentation">📖</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/Roxedus"><img src="https://avatars.githubusercontent.com/u/7110194?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Roxedus</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Roxedus" title="Documentation">📖</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/WoisWoi"><img src="https://avatars.githubusercontent.com/u/75491231?v=4?s=100" width="100px;" alt=""/><br /><sub><b>WoisWoi</b></sub></a><br /><a href="#translation-WoisWoi" title="Translation">🌍</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/HubDuck"><img src="https://avatars.githubusercontent.com/u/77843475?v=4?s=100" width="100px;" alt=""/><br /><sub><b>HubDuck</b></sub></a><br /><a href="#translation-HubDuck" title="Translation">🌍</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/costaht"><img src="https://avatars.githubusercontent.com/u/50637431?v=4?s=100" width="100px;" alt=""/><br /><sub><b>costaht</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=costaht" title="Documentation">📖</a> <a href="#translation-costaht" title="Translation">🌍</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Reverse Proxy Examples
|
# Reverse Proxy Examples
|
||||||
|
|
||||||
{% hint style="warning" %}
|
{% 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 %}
|
{% 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:
|
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/).
|
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`:
|
Add the following configuration to a new file `/etc/nginx/sites-available/overseerr.example.com.conf`:
|
||||||
|
|
||||||
```text
|
```nginx
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name overseerr.example.com;
|
server_name overseerr.example.com;
|
||||||
@@ -111,6 +114,46 @@ Then, create a symlink to `/etc/nginx/sites-enabled`:
|
|||||||
```bash
|
```bash
|
||||||
sudo ln -s /etc/nginx/sites-available/overseerr.example.com.conf /etc/nginx/sites-enabled/overseerr.example.com.conf
|
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:
|
Next, test the configuration:
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ After running Overseerr for the first time, configure it by visiting the web UI
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
|
--name overseerr \
|
||||||
-e LOG_LEVEL=info \
|
-e LOG_LEVEL=info \
|
||||||
-e TZ=Asia/Tokyo \
|
-e TZ=Asia/Tokyo \
|
||||||
-p 5055:5055 \
|
-p 5055:5055 \
|
||||||
@@ -25,10 +26,35 @@ docker run -d \
|
|||||||
|
|
||||||
{% endtab %}
|
{% 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" %}
|
{% tab title="UID/GID" %}
|
||||||
|
|
||||||
```text
|
```text
|
||||||
docker run -d \
|
docker run -d \
|
||||||
|
--name overseerr \
|
||||||
--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ] \
|
--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ] \
|
||||||
-e LOG_LEVEL=info \
|
-e LOG_LEVEL=info \
|
||||||
-e TZ=Asia/Tokyo \
|
-e TZ=Asia/Tokyo \
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ These variables are usually the target user of the notification.
|
|||||||
- `{{notifyuser_email}}` Target user's email.
|
- `{{notifyuser_email}}` Target user's email.
|
||||||
- `{{notifyuser_avatar}}` Target user's avatar.
|
- `{{notifyuser_avatar}}` Target user's avatar.
|
||||||
- `{{notifyuser_settings_discordId}}` Target user's discord ID (if one is set).
|
- `{{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
|
### Media
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ components:
|
|||||||
default: true
|
default: true
|
||||||
discordId:
|
discordId:
|
||||||
type: string
|
type: string
|
||||||
|
telegramChatId:
|
||||||
|
type: string
|
||||||
|
telegramSendSilently:
|
||||||
|
type: boolean
|
||||||
required:
|
required:
|
||||||
- enableNotifications
|
- enableNotifications
|
||||||
MainSettings:
|
MainSettings:
|
||||||
@@ -574,6 +578,19 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
|
Network:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
logoPath:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
originCountry:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
RelatedVideo:
|
RelatedVideo:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -887,6 +904,8 @@ components:
|
|||||||
$ref: '#/components/schemas/Season'
|
$ref: '#/components/schemas/Season'
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
|
tagline:
|
||||||
|
type: string
|
||||||
type:
|
type:
|
||||||
type: string
|
type: string
|
||||||
voteAverage:
|
voteAverage:
|
||||||
@@ -1085,6 +1104,10 @@ components:
|
|||||||
options:
|
options:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
botUsername:
|
||||||
|
type: string
|
||||||
|
botAvatarUrl:
|
||||||
|
type: string
|
||||||
webhookUrl:
|
webhookUrl:
|
||||||
type: string
|
type: string
|
||||||
SlackSettings:
|
SlackSettings:
|
||||||
@@ -1129,10 +1152,14 @@ components:
|
|||||||
options:
|
options:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
botUsername:
|
||||||
|
type: string
|
||||||
botAPI:
|
botAPI:
|
||||||
type: string
|
type: string
|
||||||
chatId:
|
chatId:
|
||||||
type: string
|
type: string
|
||||||
|
sendSilently:
|
||||||
|
type: boolean
|
||||||
PushbulletSettings:
|
PushbulletSettings:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -1171,9 +1198,6 @@ components:
|
|||||||
enabled:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: true
|
example: true
|
||||||
autoapprovalEnabled:
|
|
||||||
type: boolean
|
|
||||||
example: false
|
|
||||||
NotificationEmailSettings:
|
NotificationEmailSettings:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -1532,6 +1556,12 @@ components:
|
|||||||
discordId:
|
discordId:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
|
telegramChatId:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
telegramSendSilently:
|
||||||
|
type: boolean
|
||||||
|
nullable: true
|
||||||
required:
|
required:
|
||||||
- enableNotifications
|
- enableNotifications
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
@@ -1693,13 +1723,13 @@ paths:
|
|||||||
$ref: '#/components/schemas/PlexLibrary'
|
$ref: '#/components/schemas/PlexLibrary'
|
||||||
/settings/plex/sync:
|
/settings/plex/sync:
|
||||||
get:
|
get:
|
||||||
summary: Get status of full Plex library sync
|
summary: Get status of full Plex library scan
|
||||||
description: Returns sync progress in a JSON array.
|
description: Returns scan progress in a JSON array.
|
||||||
tags:
|
tags:
|
||||||
- settings
|
- settings
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Status of Plex sync
|
description: Status of Plex scan
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@@ -1721,8 +1751,8 @@ paths:
|
|||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/PlexLibrary'
|
$ref: '#/components/schemas/PlexLibrary'
|
||||||
post:
|
post:
|
||||||
summary: Start full Plex library sync
|
summary: Start full Plex library scan
|
||||||
description: Runs a full Plex library sync and returns the progress in a JSON array.
|
description: Runs a full Plex library scan and returns the progress in a JSON array.
|
||||||
tags:
|
tags:
|
||||||
- settings
|
- settings
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -1739,7 +1769,7 @@ paths:
|
|||||||
example: false
|
example: false
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Status of Plex sync
|
description: Status of Plex scan
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@@ -3223,6 +3253,16 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
example: en
|
example: en
|
||||||
|
- in: query
|
||||||
|
name: genre
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 18
|
||||||
|
- in: query
|
||||||
|
name: studio
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Results
|
description: Results
|
||||||
@@ -3244,6 +3284,147 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/MovieResult'
|
$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:
|
/discover/movies/upcoming:
|
||||||
get:
|
get:
|
||||||
summary: Upcoming movies
|
summary: Upcoming movies
|
||||||
@@ -3301,6 +3482,16 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
example: en
|
example: en
|
||||||
|
- in: query
|
||||||
|
name: genre
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 18
|
||||||
|
- in: query
|
||||||
|
name: network
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Results
|
description: Results
|
||||||
@@ -3322,6 +3513,147 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/TvResult'
|
$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:
|
/discover/tv/upcoming:
|
||||||
get:
|
get:
|
||||||
summary: Discover Upcoming TV shows
|
summary: Discover Upcoming TV shows
|
||||||
@@ -4326,6 +4658,8 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
iso_3166_1:
|
iso_3166_1:
|
||||||
@@ -4346,6 +4680,8 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
iso_639_1:
|
iso_639_1:
|
||||||
@@ -4357,6 +4693,102 @@ paths:
|
|||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
example: English
|
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:
|
security:
|
||||||
- cookieAuth: []
|
- cookieAuth: []
|
||||||
|
|||||||
71
package.json
71
package.json
@@ -20,9 +20,10 @@
|
|||||||
"@headlessui/react": "^0.3.1",
|
"@headlessui/react": "^0.3.1",
|
||||||
"@supercharge/request-ip": "^1.1.2",
|
"@supercharge/request-ip": "^1.1.2",
|
||||||
"@svgr/webpack": "^5.5.0",
|
"@svgr/webpack": "^5.5.0",
|
||||||
|
"@tanem/react-nprogress": "^3.0.57",
|
||||||
"ace-builds": "^1.4.12",
|
"ace-builds": "^1.4.12",
|
||||||
"axios": "^0.21.1",
|
"axios": "^0.21.1",
|
||||||
"bcrypt": "^5.0.0",
|
"bcrypt": "^5.0.1",
|
||||||
"body-parser": "^1.19.0",
|
"body-parser": "^1.19.0",
|
||||||
"bowser": "^2.11.0",
|
"bowser": "^2.11.0",
|
||||||
"connect-typeorm": "^1.1.4",
|
"connect-typeorm": "^1.1.4",
|
||||||
@@ -31,7 +32,7 @@
|
|||||||
"csurf": "^1.11.0",
|
"csurf": "^1.11.0",
|
||||||
"email-templates": "^8.0.3",
|
"email-templates": "^8.0.3",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"express-openapi-validator": "^4.11.0",
|
"express-openapi-validator": "^4.12.4",
|
||||||
"express-session": "^1.17.1",
|
"express-session": "^1.17.1",
|
||||||
"formik": "^2.2.6",
|
"formik": "^2.2.6",
|
||||||
"gravatar-url": "^3.1.0",
|
"gravatar-url": "^3.1.0",
|
||||||
@@ -40,16 +41,17 @@
|
|||||||
"next": "10.0.3",
|
"next": "10.0.3",
|
||||||
"node-cache": "^5.1.2",
|
"node-cache": "^5.1.2",
|
||||||
"node-schedule": "^2.0.0",
|
"node-schedule": "^2.0.0",
|
||||||
"nodemailer": "^6.4.18",
|
"nodemailer": "^6.5.0",
|
||||||
"nookies": "^2.5.2",
|
"nookies": "^2.5.2",
|
||||||
|
"openpgp": "^5.0.0-1",
|
||||||
"plex-api": "^5.3.1",
|
"plex-api": "^5.3.1",
|
||||||
"pug": "^3.0.0",
|
"pug": "^3.0.2",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-ace": "^9.3.0",
|
"react-ace": "^9.3.0",
|
||||||
"react-animate-height": "^2.0.23",
|
"react-animate-height": "^2.0.23",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
"react-intersection-observer": "^8.31.0",
|
"react-intersection-observer": "^8.31.0",
|
||||||
"react-intl": "^5.12.5",
|
"react-intl": "^5.13.2",
|
||||||
"react-markdown": "^5.0.3",
|
"react-markdown": "^5.0.3",
|
||||||
"react-spring": "^8.0.27",
|
"react-spring": "^8.0.27",
|
||||||
"react-toast-notifications": "^2.4.3",
|
"react-toast-notifications": "^2.4.3",
|
||||||
@@ -60,19 +62,19 @@
|
|||||||
"secure-random-password": "^0.2.2",
|
"secure-random-password": "^0.2.2",
|
||||||
"sqlite3": "^5.0.2",
|
"sqlite3": "^5.0.2",
|
||||||
"swagger-ui-express": "^4.1.6",
|
"swagger-ui-express": "^4.1.6",
|
||||||
"swr": "^0.4.2",
|
"swr": "^0.5.1",
|
||||||
"typeorm": "^0.2.31",
|
"typeorm": "^0.2.31",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"winston": "^3.3.3",
|
"winston": "^3.3.3",
|
||||||
"winston-daily-rotate-file": "^4.5.0",
|
"winston-daily-rotate-file": "^4.5.1",
|
||||||
"xml2js": "^0.4.23",
|
"xml2js": "^0.4.23",
|
||||||
"yamljs": "^0.3.0",
|
"yamljs": "^0.3.0",
|
||||||
"yup": "^0.32.9"
|
"yup": "^0.32.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/cli": "^7.12.17",
|
"@babel/cli": "^7.13.10",
|
||||||
"@commitlint/cli": "^11.0.0",
|
"@commitlint/cli": "^12.0.1",
|
||||||
"@commitlint/config-conventional": "^11.0.0",
|
"@commitlint/config-conventional": "^12.0.1",
|
||||||
"@semantic-release/changelog": "^5.0.1",
|
"@semantic-release/changelog": "^5.0.1",
|
||||||
"@semantic-release/commit-analyzer": "^8.0.1",
|
"@semantic-release/commit-analyzer": "^8.0.1",
|
||||||
"@semantic-release/exec": "^5.0.0",
|
"@semantic-release/exec": "^5.0.0",
|
||||||
@@ -89,11 +91,11 @@
|
|||||||
"@types/express": "^4.17.11",
|
"@types/express": "^4.17.11",
|
||||||
"@types/express-session": "^1.17.3",
|
"@types/express-session": "^1.17.3",
|
||||||
"@types/lodash": "^4.14.168",
|
"@types/lodash": "^4.14.168",
|
||||||
"@types/node": "^14.14.31",
|
"@types/node": "^14.14.34",
|
||||||
"@types/node-schedule": "^1.3.1",
|
"@types/node-schedule": "^1.3.1",
|
||||||
"@types/nodemailer": "^6.4.0",
|
"@types/nodemailer": "^6.4.1",
|
||||||
"@types/react": "^17.0.2",
|
"@types/react": "^17.0.3",
|
||||||
"@types/react-dom": "^17.0.1",
|
"@types/react-dom": "^17.0.2",
|
||||||
"@types/react-toast-notifications": "^2.4.0",
|
"@types/react-toast-notifications": "^2.4.0",
|
||||||
"@types/react-transition-group": "^4.4.1",
|
"@types/react-transition-group": "^4.4.1",
|
||||||
"@types/secure-random-password": "^0.2.0",
|
"@types/secure-random-password": "^0.2.0",
|
||||||
@@ -102,17 +104,17 @@
|
|||||||
"@types/xml2js": "^0.4.8",
|
"@types/xml2js": "^0.4.8",
|
||||||
"@types/yamljs": "^0.2.31",
|
"@types/yamljs": "^0.2.31",
|
||||||
"@types/yup": "^0.29.11",
|
"@types/yup": "^0.29.11",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.15.1",
|
"@typescript-eslint/eslint-plugin": "^4.17.0",
|
||||||
"@typescript-eslint/parser": "^4.15.1",
|
"@typescript-eslint/parser": "^4.17.0",
|
||||||
"autoprefixer": "^10.2.4",
|
"autoprefixer": "^10.2.5",
|
||||||
"babel-plugin-react-intl": "^8.2.25",
|
"babel-plugin-react-intl": "^8.2.25",
|
||||||
"babel-plugin-react-intl-auto": "^3.3.0",
|
"babel-plugin-react-intl-auto": "^3.3.0",
|
||||||
"commitizen": "^4.2.3",
|
"commitizen": "^4.2.3",
|
||||||
"copyfiles": "^2.4.1",
|
"copyfiles": "^2.4.1",
|
||||||
"cz-conventional-changelog": "^3.3.0",
|
"cz-conventional-changelog": "^3.3.0",
|
||||||
"eslint": "^7.20.0",
|
"eslint": "^7.21.0",
|
||||||
"eslint-config-prettier": "^7.2.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-jsx-a11y": "^6.4.1",
|
||||||
"eslint-plugin-prettier": "^3.3.1",
|
"eslint-plugin-prettier": "^3.3.1",
|
||||||
"eslint-plugin-react": "^7.22.0",
|
"eslint-plugin-react": "^7.22.0",
|
||||||
@@ -121,14 +123,14 @@
|
|||||||
"husky": "4.3.8",
|
"husky": "4.3.8",
|
||||||
"lint-staged": "^10.5.4",
|
"lint-staged": "^10.5.4",
|
||||||
"nodemon": "^2.0.7",
|
"nodemon": "^2.0.7",
|
||||||
"postcss": "^8.2.6",
|
"postcss": "^8.2.8",
|
||||||
"postcss-preset-env": "^6.7.0",
|
"postcss-preset-env": "^6.7.0",
|
||||||
"prettier": "^2.2.1",
|
"prettier": "^2.2.1",
|
||||||
"semantic-release": "^17.3.9",
|
"semantic-release": "^17.4.2",
|
||||||
"semantic-release-docker": "^2.2.0",
|
"semantic-release-docker-buildx": "^1.0.1",
|
||||||
"tailwindcss": "npm:@tailwindcss/postcss7-compat",
|
"tailwindcss": "npm:@tailwindcss/postcss7-compat",
|
||||||
"ts-node": "^9.1.1",
|
"ts-node": "^9.1.1",
|
||||||
"typescript": "^4.1.5"
|
"typescript": "^4.2.3"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"sqlite3/node-gyp": "^5.1.0"
|
"sqlite3/node-gyp": "^5.1.0"
|
||||||
@@ -186,13 +188,7 @@
|
|||||||
"message": "chore(release): ${nextRelease.version}"
|
"message": "chore(release): ${nextRelease.version}"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
"semantic-release-docker-buildx",
|
||||||
"@semantic-release/exec",
|
|
||||||
{
|
|
||||||
"prepareCmd": "docker build --build-arg COMMIT_TAG=$GITHUB_SHA -t sctx/overseerr ."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"semantic-release-docker",
|
|
||||||
[
|
[
|
||||||
"@semantic-release/github",
|
"@semantic-release/github",
|
||||||
{
|
{
|
||||||
@@ -206,8 +202,19 @@
|
|||||||
"npmPublish": false,
|
"npmPublish": false,
|
||||||
"publish": [
|
"publish": [
|
||||||
{
|
{
|
||||||
"path": "semantic-release-docker",
|
"path": "semantic-release-docker-buildx",
|
||||||
"name": "sctx/overseerr"
|
"buildArgs": {
|
||||||
|
"COMMIT_TAG": "$GITHUB_SHA"
|
||||||
|
},
|
||||||
|
"imageNames": [
|
||||||
|
"sctx/overseerr",
|
||||||
|
"ghcr.io/sct/overseerr"
|
||||||
|
],
|
||||||
|
"platforms": [
|
||||||
|
"linux/amd64",
|
||||||
|
"linux/arm64",
|
||||||
|
"linux/arm/v7"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"@semantic-release/github"
|
"@semantic-release/github"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?><svg version="1.1" viewBox="0 0 1024 1025" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="a" width="1024" height="1024"/><clipPath id="b"><use clip-rule="evenodd" xlink:href="#a"/></clipPath></defs><g clip-path="url(#b)"><use fill="#24292E" fill-opacity="0" xlink:href="#a"/><g transform="translate(70 18)"><path d="m105.3 156.12l7.522 719.97c-60.173 7.579-105.3-22.736-105.3-83.364l-7.5216-598.71c0-189.46 173-234.94 278.3-159.15l534.03 310.72c75.216 53.05 90.259 151.57 52.651 219.78-7.522-53.05-30.086-83.365-75.216-113.68l-601.73-341.04c-45.13-30.315-82.738-22.736-82.738 45.471z" fill="#fff"/><path transform="translate(60.173 535.05)" d="m0 378.93c45.13 15.158 90.259 7.579 127.87-15.157l616.77-363.77c37.607 53.05 30.086 106.1-15.044 136.42l-518.99 303.14c-75.216 37.893-173 0-210.6-60.629z" fill="#fff"/><path transform="translate(240.69 284.95)" d="M0 416.822L368.558 204.622L7.52159 0L0 416.822Z" fill="#FFC230"/></g></g></svg>
|
<?xml version="1.0" encoding="UTF-8"?><svg version="1.1" viewBox="0 0 1000 1115.2" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xlink="http://www.w3.org/1999/xlink"><metadata><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><defs><path id="a" d="m0 0h1024v1024h-1024z"/></defs><use transform="rotate(.704 1914.4 -5491.6)" width="100%" height="100%" fill="#ffffff" fill-opacity="0" xlink:href="#a"/><g transform="matrix(1.1348 0 0 1.1348 -.0011348 -.013738)"><path d="m105.76 154.15-1.263 714.59c-60.261 6.782-105.02-23.858-104.28-84.025l-0.216-594.25c2.312-188.02 175.85-231.02 280.22-154.52l530.2 314.93c74.563 53.572 88.403 151.53 49.965 218.76-6.873-52.739-29.067-83.101-73.823-113.74l-597.52-345.84c-44.756-30.639-82.453-23.58-83.286 44.109zm-54.377 751.54c44.941 15.597 90.16 8.63 128.04-13.47l621.16-353.43c36.958 53.109 28.79 105.66-16.706 135.19l-522.65 294.46c-75.673 36.68-172.98-2.127-209.85-62.757z" fill="#fff"/><path d="m240.52 702.59 365.02-216.68-364.35-197.07z" fill="#ffc230"/></g></svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1023 B After Width: | Height: | Size: 1.2 KiB |
@@ -207,11 +207,6 @@ class SonarrAPI extends ExternalAPI {
|
|||||||
if (series.id) {
|
if (series.id) {
|
||||||
series.seasons = this.buildSeasonList(options.seasons, series.seasons);
|
series.seasons = this.buildSeasonList(options.seasons, series.seasons);
|
||||||
|
|
||||||
series.addOptions = {
|
|
||||||
ignoreEpisodesWithFiles: true,
|
|
||||||
searchForMissingEpisodes: options.searchNow,
|
|
||||||
};
|
|
||||||
|
|
||||||
const newSeriesResponse = await this.axios.put<SonarrSeries>(
|
const newSeriesResponse = await this.axios.put<SonarrSeries>(
|
||||||
'/series',
|
'/series',
|
||||||
series
|
series
|
||||||
@@ -225,6 +220,9 @@ class SonarrAPI extends ExternalAPI {
|
|||||||
label: 'Sonarr',
|
label: 'Sonarr',
|
||||||
movie: newSeriesResponse.data,
|
movie: newSeriesResponse.data,
|
||||||
});
|
});
|
||||||
|
if (options.searchNow) {
|
||||||
|
this.searchSeries(newSeriesResponse.data.id);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.error('Failed to update series in Sonarr', {
|
logger.error('Failed to update series in Sonarr', {
|
||||||
label: 'Sonarr',
|
label: 'Sonarr',
|
||||||
@@ -350,6 +348,33 @@ class SonarrAPI extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async searchSeries(seriesId: number): Promise<void> {
|
||||||
|
logger.info('Executing series search command', {
|
||||||
|
label: 'Sonarr API',
|
||||||
|
seriesId,
|
||||||
|
});
|
||||||
|
await this.runCommand('SeriesSearch', { seriesId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runCommand(
|
||||||
|
commandName: string,
|
||||||
|
options: Record<string, unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
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(
|
private buildSeasonList(
|
||||||
seasons: number[],
|
seasons: number[],
|
||||||
existingSeasons?: SonarrSeason[]
|
existingSeasons?: SonarrSeason[]
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import ExternalAPI from '../externalapi';
|
|||||||
import {
|
import {
|
||||||
TmdbCollection,
|
TmdbCollection,
|
||||||
TmdbExternalIdResponse,
|
TmdbExternalIdResponse,
|
||||||
|
TmdbGenre,
|
||||||
|
TmdbGenresResult,
|
||||||
TmdbLanguage,
|
TmdbLanguage,
|
||||||
TmdbMovieDetails,
|
TmdbMovieDetails,
|
||||||
|
TmdbNetwork,
|
||||||
TmdbPersonCombinedCredits,
|
TmdbPersonCombinedCredits,
|
||||||
TmdbPersonDetail,
|
TmdbPersonDetail,
|
||||||
TmdbRegion,
|
TmdbRegion,
|
||||||
@@ -15,6 +18,7 @@ import {
|
|||||||
TmdbSeasonWithEpisodes,
|
TmdbSeasonWithEpisodes,
|
||||||
TmdbTvDetails,
|
TmdbTvDetails,
|
||||||
TmdbUpcomingMoviesResponse,
|
TmdbUpcomingMoviesResponse,
|
||||||
|
TmdbProductionCompany,
|
||||||
} from './interfaces';
|
} from './interfaces';
|
||||||
|
|
||||||
interface SearchOptions {
|
interface SearchOptions {
|
||||||
@@ -30,6 +34,9 @@ interface DiscoverMovieOptions {
|
|||||||
language?: string;
|
language?: string;
|
||||||
primaryReleaseDateGte?: string;
|
primaryReleaseDateGte?: string;
|
||||||
primaryReleaseDateLte?: string;
|
primaryReleaseDateLte?: string;
|
||||||
|
originalLanguage?: string;
|
||||||
|
genre?: number;
|
||||||
|
studio?: number;
|
||||||
sortBy?:
|
sortBy?:
|
||||||
| 'popularity.asc'
|
| 'popularity.asc'
|
||||||
| 'popularity.desc'
|
| 'popularity.desc'
|
||||||
@@ -53,6 +60,9 @@ interface DiscoverTvOptions {
|
|||||||
firstAirDateGte?: string;
|
firstAirDateGte?: string;
|
||||||
firstAirDateLte?: string;
|
firstAirDateLte?: string;
|
||||||
includeEmptyReleaseDate?: boolean;
|
includeEmptyReleaseDate?: boolean;
|
||||||
|
originalLanguage?: string;
|
||||||
|
genre?: number;
|
||||||
|
network?: number;
|
||||||
sortBy?:
|
sortBy?:
|
||||||
| 'popularity.asc'
|
| 'popularity.asc'
|
||||||
| 'popularity.desc'
|
| 'popularity.desc'
|
||||||
@@ -120,7 +130,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} 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;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
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;
|
return data;
|
||||||
} catch (e) {
|
} 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;
|
return data;
|
||||||
} catch (e) {
|
} 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;
|
return data;
|
||||||
} catch (e) {
|
} 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;
|
return data;
|
||||||
} catch (e) {
|
} 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;
|
return data;
|
||||||
} catch (e) {
|
} 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;
|
return data;
|
||||||
} catch (e) {
|
} 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;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
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;
|
return data;
|
||||||
} catch (e) {
|
} 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',
|
language = 'en',
|
||||||
primaryReleaseDateGte,
|
primaryReleaseDateGte,
|
||||||
primaryReleaseDateLte,
|
primaryReleaseDateLte,
|
||||||
|
originalLanguage,
|
||||||
|
genre,
|
||||||
|
studio,
|
||||||
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
|
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
|
||||||
@@ -368,17 +381,18 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
page,
|
page,
|
||||||
include_adult: includeAdult,
|
include_adult: includeAdult,
|
||||||
language,
|
language,
|
||||||
with_release_type: '3|2',
|
|
||||||
region: this.region,
|
region: this.region,
|
||||||
with_original_language: this.originalLanguage,
|
with_original_language: originalLanguage ?? this.originalLanguage,
|
||||||
'primary_release_date.gte': primaryReleaseDateGte,
|
'primary_release_date.gte': primaryReleaseDateGte,
|
||||||
'primary_release_date.lte': primaryReleaseDateLte,
|
'primary_release_date.lte': primaryReleaseDateLte,
|
||||||
|
with_genres: genre,
|
||||||
|
with_companies: studio,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} 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,
|
firstAirDateGte,
|
||||||
firstAirDateLte,
|
firstAirDateLte,
|
||||||
includeEmptyReleaseDate = false,
|
includeEmptyReleaseDate = false,
|
||||||
|
originalLanguage,
|
||||||
|
genre,
|
||||||
|
network,
|
||||||
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
|
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
|
||||||
@@ -399,14 +416,16 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
region: this.region,
|
region: this.region,
|
||||||
'first_air_date.gte': firstAirDateGte,
|
'first_air_date.gte': firstAirDateGte,
|
||||||
'first_air_date.lte': firstAirDateLte,
|
'first_air_date.lte': firstAirDateLte,
|
||||||
with_original_language: this.originalLanguage,
|
with_original_language: originalLanguage ?? this.originalLanguage,
|
||||||
include_null_first_air_dates: includeEmptyReleaseDate,
|
include_null_first_air_dates: includeEmptyReleaseDate,
|
||||||
|
with_genres: genre,
|
||||||
|
with_networks: network,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} 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;
|
return data;
|
||||||
} catch (e) {
|
} 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;
|
return data;
|
||||||
} catch (e) {
|
} 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;
|
return data;
|
||||||
} catch (e) {
|
} 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;
|
return data;
|
||||||
} catch (e) {
|
} 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;
|
return data;
|
||||||
} catch (e) {
|
} 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(
|
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) {
|
} catch (e) {
|
||||||
throw new Error(
|
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(
|
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) {
|
} catch (e) {
|
||||||
throw new Error(
|
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;
|
return data;
|
||||||
} catch (e) {
|
} 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;
|
return regions;
|
||||||
} catch (e) {
|
} 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;
|
return languages;
|
||||||
} catch (e) {
|
} 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<TmdbProductionCompany> {
|
||||||
|
try {
|
||||||
|
const data = await this.get<TmdbProductionCompany>(
|
||||||
|
`/company/${studioId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[TMDb] Failed to fetch movie studio: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getNetwork(networkId: number): Promise<TmdbNetwork> {
|
||||||
|
try {
|
||||||
|
const data = await this.get<TmdbNetwork>(`/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<TmdbGenre[]> {
|
||||||
|
try {
|
||||||
|
const data = await this.get<TmdbGenresResult>(
|
||||||
|
'/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<TmdbGenre[]> {
|
||||||
|
try {
|
||||||
|
const data = await this.get<TmdbGenresResult>(
|
||||||
|
'/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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,16 @@ export interface TmdbExternalIds {
|
|||||||
twitter_id?: string;
|
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 {
|
export interface TmdbMovieDetails {
|
||||||
id: number;
|
id: number;
|
||||||
imdb_id?: string;
|
imdb_id?: string;
|
||||||
@@ -125,12 +135,7 @@ export interface TmdbMovieDetails {
|
|||||||
original_title: string;
|
original_title: string;
|
||||||
overview?: string;
|
overview?: string;
|
||||||
popularity: number;
|
popularity: number;
|
||||||
production_companies: {
|
production_companies: TmdbProductionCompany[];
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
logo_path?: string;
|
|
||||||
origin_country: string;
|
|
||||||
}[];
|
|
||||||
production_countries: {
|
production_countries: {
|
||||||
iso_3166_1: string;
|
iso_3166_1: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -227,12 +232,7 @@ export interface TmdbTvDetails {
|
|||||||
last_episode_to_air?: TmdbTvEpisodeResult;
|
last_episode_to_air?: TmdbTvEpisodeResult;
|
||||||
name: string;
|
name: string;
|
||||||
next_episode_to_air?: TmdbTvEpisodeResult;
|
next_episode_to_air?: TmdbTvEpisodeResult;
|
||||||
networks: {
|
networks: TmdbNetwork[];
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
logo_path: string;
|
|
||||||
origin_country: string;
|
|
||||||
}[];
|
|
||||||
number_of_episodes: number;
|
number_of_episodes: number;
|
||||||
number_of_seasons: number;
|
number_of_seasons: number;
|
||||||
origin_country: string[];
|
origin_country: string[];
|
||||||
@@ -254,6 +254,7 @@ export interface TmdbTvDetails {
|
|||||||
}[];
|
}[];
|
||||||
seasons: TmdbTvSeasonResult[];
|
seasons: TmdbTvSeasonResult[];
|
||||||
status: string;
|
status: string;
|
||||||
|
tagline?: string;
|
||||||
type: string;
|
type: string;
|
||||||
vote_average: number;
|
vote_average: number;
|
||||||
vote_count: number;
|
vote_count: number;
|
||||||
@@ -381,3 +382,21 @@ export interface TmdbLanguage {
|
|||||||
english_name: string;
|
english_name: string;
|
||||||
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export class MediaRequest {
|
|||||||
* auto approved content
|
* auto approved content
|
||||||
*/
|
*/
|
||||||
@AfterUpdate()
|
@AfterUpdate()
|
||||||
public async notifyApprovedOrDeclined(): Promise<void> {
|
public async notifyApprovedOrDeclined(autoApproved = false): Promise<void> {
|
||||||
if (
|
if (
|
||||||
this.status === MediaRequestStatus.APPROVED ||
|
this.status === MediaRequestStatus.APPROVED ||
|
||||||
this.status === MediaRequestStatus.DECLINED
|
this.status === MediaRequestStatus.DECLINED
|
||||||
@@ -171,7 +171,9 @@ export class MediaRequest {
|
|||||||
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
|
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
|
||||||
notificationManager.sendNotification(
|
notificationManager.sendNotification(
|
||||||
this.status === MediaRequestStatus.APPROVED
|
this.status === MediaRequestStatus.APPROVED
|
||||||
? Notification.MEDIA_APPROVED
|
? autoApproved
|
||||||
|
? Notification.MEDIA_AUTO_APPROVED
|
||||||
|
: Notification.MEDIA_APPROVED
|
||||||
: Notification.MEDIA_DECLINED,
|
: Notification.MEDIA_DECLINED,
|
||||||
{
|
{
|
||||||
subject: movie.title,
|
subject: movie.title,
|
||||||
@@ -186,7 +188,9 @@ export class MediaRequest {
|
|||||||
const tv = await tmdb.getTvShow({ tvId: this.media.tmdbId });
|
const tv = await tmdb.getTvShow({ tvId: this.media.tmdbId });
|
||||||
notificationManager.sendNotification(
|
notificationManager.sendNotification(
|
||||||
this.status === MediaRequestStatus.APPROVED
|
this.status === MediaRequestStatus.APPROVED
|
||||||
? Notification.MEDIA_APPROVED
|
? autoApproved
|
||||||
|
? Notification.MEDIA_AUTO_APPROVED
|
||||||
|
: Notification.MEDIA_APPROVED
|
||||||
: Notification.MEDIA_DECLINED,
|
: Notification.MEDIA_DECLINED,
|
||||||
{
|
{
|
||||||
subject: tv.name,
|
subject: tv.name,
|
||||||
@@ -211,13 +215,8 @@ export class MediaRequest {
|
|||||||
|
|
||||||
@AfterInsert()
|
@AfterInsert()
|
||||||
public async autoapprovalNotification(): Promise<void> {
|
public async autoapprovalNotification(): Promise<void> {
|
||||||
const settings = getSettings().notifications;
|
if (this.status === MediaRequestStatus.APPROVED) {
|
||||||
|
this.notifyApprovedOrDeclined(true);
|
||||||
if (
|
|
||||||
settings.autoapprovalEnabled &&
|
|
||||||
this.status === MediaRequestStatus.APPROVED
|
|
||||||
) {
|
|
||||||
this.notifyApprovedOrDeclined();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,18 @@ export class UserSettings {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public discordId?: string;
|
public discordId?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public telegramChatId?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public telegramSendSilently?: boolean;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public region?: string;
|
public region?: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public originalLanguage?: string;
|
public originalLanguage?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public pgpKey?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,9 @@ export interface UserSettingsGeneralResponse {
|
|||||||
|
|
||||||
export interface UserSettingsNotificationsResponse {
|
export interface UserSettingsNotificationsResponse {
|
||||||
enableNotifications: boolean;
|
enableNotifications: boolean;
|
||||||
|
telegramBotUsername?: string;
|
||||||
discordId?: string;
|
discordId?: string;
|
||||||
|
telegramChatId?: string;
|
||||||
|
telegramSendSilently?: boolean;
|
||||||
|
pgpKey?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void>((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<string, unknown>
|
|
||||||
): 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<void> {
|
|
||||||
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 });
|
|
||||||
@@ -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<void>((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<string, unknown>
|
|
||||||
): void {
|
|
||||||
logger[level](message, { label: 'Radarr Sync', ...optional });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const jobRadarrSync = new JobRadarrSync();
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import schedule from 'node-schedule';
|
import schedule from 'node-schedule';
|
||||||
import { jobPlexFullSync, jobPlexRecentSync } from './plexsync';
|
|
||||||
import logger from '../logger';
|
import logger from '../logger';
|
||||||
import { jobRadarrSync } from './radarrsync';
|
|
||||||
import { jobSonarrSync } from './sonarrsync';
|
|
||||||
import downloadTracker from '../lib/downloadtracker';
|
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 {
|
interface ScheduledJob {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,58 +17,60 @@ interface ScheduledJob {
|
|||||||
export const scheduledJobs: ScheduledJob[] = [];
|
export const scheduledJobs: ScheduledJob[] = [];
|
||||||
|
|
||||||
export const startJobs = (): void => {
|
export const startJobs = (): void => {
|
||||||
// Run recently added plex sync every 5 minutes
|
// Run recently added plex scan every 5 minutes
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'plex-recently-added-sync',
|
id: 'plex-recently-added-scan',
|
||||||
name: 'Plex Recently Added Sync',
|
name: 'Plex Recently Added Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
job: schedule.scheduleJob('0 */5 * * * *', () => {
|
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',
|
label: 'Jobs',
|
||||||
});
|
});
|
||||||
jobPlexRecentSync.run();
|
plexRecentScanner.run();
|
||||||
}),
|
}),
|
||||||
running: () => jobPlexRecentSync.status().running,
|
running: () => plexRecentScanner.status().running,
|
||||||
cancelFn: () => jobPlexRecentSync.cancel(),
|
cancelFn: () => plexRecentScanner.cancel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Run full plex sync every 24 hours
|
// Run full plex scan every 24 hours
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'plex-full-sync',
|
id: 'plex-full-scan',
|
||||||
name: 'Plex Full Library Sync',
|
name: 'Plex Full Library Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
job: schedule.scheduleJob('0 0 3 * * *', () => {
|
job: schedule.scheduleJob('0 0 3 * * *', () => {
|
||||||
logger.info('Starting scheduled job: Plex Full Sync', { label: 'Jobs' });
|
logger.info('Starting scheduled job: Plex Full Library Scan', {
|
||||||
jobPlexFullSync.run();
|
label: 'Jobs',
|
||||||
|
});
|
||||||
|
plexFullScanner.run();
|
||||||
}),
|
}),
|
||||||
running: () => jobPlexFullSync.status().running,
|
running: () => plexFullScanner.status().running,
|
||||||
cancelFn: () => jobPlexFullSync.cancel(),
|
cancelFn: () => plexFullScanner.cancel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Run full radarr sync every 24 hours
|
// Run full radarr scan every 24 hours
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'radarr-sync',
|
id: 'radarr-scan',
|
||||||
name: 'Radarr Sync',
|
name: 'Radarr Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
job: schedule.scheduleJob('0 0 4 * * *', () => {
|
job: schedule.scheduleJob('0 0 4 * * *', () => {
|
||||||
logger.info('Starting scheduled job: Radarr Sync', { label: 'Jobs' });
|
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
|
||||||
jobRadarrSync.run();
|
radarrScanner.run();
|
||||||
}),
|
}),
|
||||||
running: () => jobRadarrSync.status().running,
|
running: () => radarrScanner.status().running,
|
||||||
cancelFn: () => jobRadarrSync.cancel(),
|
cancelFn: () => radarrScanner.cancel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Run full sonarr sync every 24 hours
|
// Run full sonarr scan every 24 hours
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'sonarr-sync',
|
id: 'sonarr-scan',
|
||||||
name: 'Sonarr Sync',
|
name: 'Sonarr Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
job: schedule.scheduleJob('0 30 4 * * *', () => {
|
job: schedule.scheduleJob('0 30 4 * * *', () => {
|
||||||
logger.info('Starting scheduled job: Sonarr Sync', { label: 'Jobs' });
|
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
|
||||||
jobSonarrSync.run();
|
sonarrScanner.run();
|
||||||
}),
|
}),
|
||||||
running: () => jobSonarrSync.status().running,
|
running: () => sonarrScanner.status().running,
|
||||||
cancelFn: () => jobSonarrSync.cancel(),
|
cancelFn: () => sonarrScanner.cancel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Run download sync
|
// Run download sync
|
||||||
|
|||||||
@@ -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<void>((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<string, unknown>
|
|
||||||
): void {
|
|
||||||
logger[level](message, { label: 'Sonarr Sync', ...optional });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const jobSonarrSync = new JobSonarrSync();
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import Email from 'email-templates';
|
import Email from 'email-templates';
|
||||||
import { getSettings } from '../settings';
|
import { getSettings } from '../settings';
|
||||||
|
import { openpgpEncrypt } from './openpgpEncrypt';
|
||||||
class PreparedEmail extends Email {
|
class PreparedEmail extends Email {
|
||||||
public constructor() {
|
public constructor(pgpKey?: string) {
|
||||||
const settings = getSettings().notifications.agents.email;
|
const settings = getSettings().notifications.agents.email;
|
||||||
|
|
||||||
const transport = nodemailer.createTransport({
|
const transport = nodemailer.createTransport({
|
||||||
@@ -22,6 +23,16 @@ class PreparedEmail extends Email {
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
if (pgpKey) {
|
||||||
|
transport.use(
|
||||||
|
'stream',
|
||||||
|
openpgpEncrypt({
|
||||||
|
signingKey: settings.options.pgpPrivateKey,
|
||||||
|
password: settings.options.pgpPassword,
|
||||||
|
encryptionKeys: [pgpKey],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
super({
|
super({
|
||||||
message: {
|
message: {
|
||||||
from: {
|
from: {
|
||||||
|
|||||||
181
server/lib/email/openpgpEncrypt.ts
Normal file
181
server/lib/email/openpgpEncrypt.ts
Normal file
@@ -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<void> => {
|
||||||
|
// 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);
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -21,6 +21,12 @@ export abstract class BaseAgent<T extends NotificationAgentConfig> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected abstract getSettings(): T;
|
protected abstract getSettings(): T;
|
||||||
|
|
||||||
|
protected userNotificationTypes: Notification[] = [
|
||||||
|
Notification.MEDIA_APPROVED,
|
||||||
|
Notification.MEDIA_DECLINED,
|
||||||
|
Notification.MEDIA_AVAILABLE,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationAgent {
|
export interface NotificationAgent {
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ interface DiscordRichEmbed {
|
|||||||
|
|
||||||
interface DiscordWebhookPayload {
|
interface DiscordWebhookPayload {
|
||||||
embeds: DiscordRichEmbed[];
|
embeds: DiscordRichEmbed[];
|
||||||
username: string;
|
username?: string;
|
||||||
avatar_url?: string;
|
avatar_url?: string;
|
||||||
tts: boolean;
|
tts: boolean;
|
||||||
content?: string;
|
content?: string;
|
||||||
@@ -122,6 +122,7 @@ class DiscordAgent
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_APPROVED:
|
case Notification.MEDIA_APPROVED:
|
||||||
|
case Notification.MEDIA_AUTO_APPROVED:
|
||||||
color = EmbedColors.PURPLE;
|
color = EmbedColors.PURPLE;
|
||||||
fields.push({
|
fields.push({
|
||||||
name: 'Status',
|
name: 'Status',
|
||||||
@@ -155,15 +156,14 @@ class DiscordAgent
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.main.applicationUrl && payload.media) {
|
const url =
|
||||||
fields.push({
|
settings.main.applicationUrl && payload.media
|
||||||
name: `Open in ${settings.main.applicationTitle}`,
|
? `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||||
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
: undefined;
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: payload.subject,
|
title: payload.subject,
|
||||||
|
url,
|
||||||
description: payload.message,
|
description: payload.message,
|
||||||
color,
|
color,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -201,10 +201,13 @@ class DiscordAgent
|
|||||||
type: Notification,
|
type: Notification,
|
||||||
payload: NotificationPayload
|
payload: NotificationPayload
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
logger.debug('Sending discord notification', { label: 'Notifications' });
|
logger.debug('Sending Discord notification', { label: 'Notifications' });
|
||||||
try {
|
try {
|
||||||
const settings = getSettings();
|
const {
|
||||||
const webhookUrl = this.getSettings().options.webhookUrl;
|
botUsername,
|
||||||
|
botAvatarUrl,
|
||||||
|
webhookUrl,
|
||||||
|
} = this.getSettings().options;
|
||||||
|
|
||||||
if (!webhookUrl) {
|
if (!webhookUrl) {
|
||||||
return false;
|
return false;
|
||||||
@@ -214,6 +217,7 @@ class DiscordAgent
|
|||||||
let content = undefined;
|
let content = undefined;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
this.userNotificationTypes.includes(type) &&
|
||||||
payload.notifyUser.settings?.enableNotifications &&
|
payload.notifyUser.settings?.enableNotifications &&
|
||||||
payload.notifyUser.settings?.discordId
|
payload.notifyUser.settings?.discordId
|
||||||
) {
|
) {
|
||||||
@@ -222,7 +226,8 @@ class DiscordAgent
|
|||||||
}
|
}
|
||||||
|
|
||||||
await axios.post(webhookUrl, {
|
await axios.post(webhookUrl, {
|
||||||
username: settings.main.applicationTitle,
|
username: botUsername,
|
||||||
|
avatar_url: botAvatarUrl,
|
||||||
embeds: [this.buildEmbed(type, payload)],
|
embeds: [this.buildEmbed(type, payload)],
|
||||||
content,
|
content,
|
||||||
allowed_mentions: {
|
allowed_mentions: {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getRepository } from 'typeorm';
|
|||||||
import { User } from '../../../entity/User';
|
import { User } from '../../../entity/User';
|
||||||
import { Permission } from '../../permissions';
|
import { Permission } from '../../permissions';
|
||||||
import PreparedEmail from '../../email';
|
import PreparedEmail from '../../email';
|
||||||
|
import { MediaType } from '../../../constants/media';
|
||||||
|
|
||||||
class EmailAgent
|
class EmailAgent
|
||||||
extends BaseAgent<NotificationAgentEmail>
|
extends BaseAgent<NotificationAgentEmail>
|
||||||
@@ -46,7 +47,7 @@ class EmailAgent
|
|||||||
users
|
users
|
||||||
.filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS))
|
.filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS))
|
||||||
.forEach((user) => {
|
.forEach((user) => {
|
||||||
const email = new PreparedEmail();
|
const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey);
|
||||||
|
|
||||||
email.send({
|
email.send({
|
||||||
template: path.join(
|
template: path.join(
|
||||||
@@ -57,7 +58,9 @@ class EmailAgent
|
|||||||
to: user.email,
|
to: user.email,
|
||||||
},
|
},
|
||||||
locals: {
|
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,
|
mediaName: payload.subject,
|
||||||
imageUrl: payload.image,
|
imageUrl: payload.image,
|
||||||
timestamp: new Date().toTimeString(),
|
timestamp: new Date().toTimeString(),
|
||||||
@@ -67,13 +70,15 @@ class EmailAgent
|
|||||||
: undefined,
|
: undefined,
|
||||||
applicationUrl,
|
applicationUrl,
|
||||||
applicationTitle,
|
applicationTitle,
|
||||||
requestType: 'New Request',
|
requestType: `New ${
|
||||||
|
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||||
|
} Request`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Mail notification failed to send', {
|
logger.error('Email notification failed to send', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
message: e.message,
|
message: e.message,
|
||||||
});
|
});
|
||||||
@@ -82,6 +87,100 @@ class EmailAgent
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async sendMediaFailedEmail(payload: NotificationPayload) {
|
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
|
// This is getting main settings for the whole app
|
||||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||||
try {
|
try {
|
||||||
@@ -103,8 +202,9 @@ class EmailAgent
|
|||||||
to: user.email,
|
to: user.email,
|
||||||
},
|
},
|
||||||
locals: {
|
locals: {
|
||||||
body:
|
body: `A new request for the following ${
|
||||||
"A user's new request has failed to add to Sonarr or Radarr",
|
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||||
|
} has been automatically approved:`,
|
||||||
mediaName: payload.subject,
|
mediaName: payload.subject,
|
||||||
imageUrl: payload.image,
|
imageUrl: payload.image,
|
||||||
timestamp: new Date().toTimeString(),
|
timestamp: new Date().toTimeString(),
|
||||||
@@ -114,51 +214,15 @@ class EmailAgent
|
|||||||
: undefined,
|
: undefined,
|
||||||
applicationUrl,
|
applicationUrl,
|
||||||
applicationTitle,
|
applicationTitle,
|
||||||
requestType: 'Failed Request',
|
requestType: `${
|
||||||
|
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||||
|
} Request Automatically Approved`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Mail notification failed to send', {
|
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();
|
|
||||||
|
|
||||||
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', {
|
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
message: e.message,
|
message: e.message,
|
||||||
});
|
});
|
||||||
@@ -170,7 +234,7 @@ class EmailAgent
|
|||||||
// This is getting main settings for the whole app
|
// This is getting main settings for the whole app
|
||||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||||
try {
|
try {
|
||||||
const email = new PreparedEmail();
|
const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey);
|
||||||
|
|
||||||
await email.send({
|
await email.send({
|
||||||
template: path.join(
|
template: path.join(
|
||||||
@@ -181,7 +245,9 @@ class EmailAgent
|
|||||||
to: payload.notifyUser.email,
|
to: payload.notifyUser.email,
|
||||||
},
|
},
|
||||||
locals: {
|
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,
|
mediaName: payload.subject,
|
||||||
imageUrl: payload.image,
|
imageUrl: payload.image,
|
||||||
timestamp: new Date().toTimeString(),
|
timestamp: new Date().toTimeString(),
|
||||||
@@ -191,12 +257,14 @@ class EmailAgent
|
|||||||
: undefined,
|
: undefined,
|
||||||
applicationUrl,
|
applicationUrl,
|
||||||
applicationTitle,
|
applicationTitle,
|
||||||
requestType: 'Request Declined',
|
requestType: `${
|
||||||
|
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||||
|
} Request Declined`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Mail notification failed to send', {
|
logger.error('Email notification failed to send', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
message: e.message,
|
message: e.message,
|
||||||
});
|
});
|
||||||
@@ -208,7 +276,7 @@ class EmailAgent
|
|||||||
// This is getting main settings for the whole app
|
// This is getting main settings for the whole app
|
||||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||||
try {
|
try {
|
||||||
const email = new PreparedEmail();
|
const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey);
|
||||||
|
|
||||||
await email.send({
|
await email.send({
|
||||||
template: path.join(
|
template: path.join(
|
||||||
@@ -219,7 +287,9 @@ class EmailAgent
|
|||||||
to: payload.notifyUser.email,
|
to: payload.notifyUser.email,
|
||||||
},
|
},
|
||||||
locals: {
|
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,
|
mediaName: payload.subject,
|
||||||
imageUrl: payload.image,
|
imageUrl: payload.image,
|
||||||
timestamp: new Date().toTimeString(),
|
timestamp: new Date().toTimeString(),
|
||||||
@@ -229,12 +299,14 @@ class EmailAgent
|
|||||||
: undefined,
|
: undefined,
|
||||||
applicationUrl,
|
applicationUrl,
|
||||||
applicationTitle,
|
applicationTitle,
|
||||||
requestType: 'Now Available',
|
requestType: `${
|
||||||
|
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||||
|
} Now Available`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Mail notification failed to send', {
|
logger.error('Email notification failed to send', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
message: e.message,
|
message: e.message,
|
||||||
});
|
});
|
||||||
@@ -246,7 +318,7 @@ class EmailAgent
|
|||||||
// This is getting main settings for the whole app
|
// This is getting main settings for the whole app
|
||||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||||
try {
|
try {
|
||||||
const email = new PreparedEmail();
|
const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey);
|
||||||
|
|
||||||
await email.send({
|
await email.send({
|
||||||
template: path.join(__dirname, '../../../templates/email/test-email'),
|
template: path.join(__dirname, '../../../templates/email/test-email'),
|
||||||
@@ -261,7 +333,7 @@ class EmailAgent
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Mail notification failed to send', {
|
logger.error('Email notification failed to send', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
message: e.message,
|
message: e.message,
|
||||||
});
|
});
|
||||||
@@ -282,6 +354,9 @@ class EmailAgent
|
|||||||
case Notification.MEDIA_APPROVED:
|
case Notification.MEDIA_APPROVED:
|
||||||
this.sendMediaApprovedEmail(payload);
|
this.sendMediaApprovedEmail(payload);
|
||||||
break;
|
break;
|
||||||
|
case Notification.MEDIA_AUTO_APPROVED:
|
||||||
|
this.sendMediaAutoApprovedEmail(payload);
|
||||||
|
break;
|
||||||
case Notification.MEDIA_DECLINED:
|
case Notification.MEDIA_DECLINED:
|
||||||
this.sendMediaDeclinedEmail(payload);
|
this.sendMediaDeclinedEmail(payload);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { hasNotificationType, Notification } from '..';
|
|||||||
import logger from '../../../logger';
|
import logger from '../../../logger';
|
||||||
import { getSettings, NotificationAgentPushbullet } from '../../settings';
|
import { getSettings, NotificationAgentPushbullet } from '../../settings';
|
||||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||||
|
import { MediaType } from '../../../constants/media';
|
||||||
|
|
||||||
interface PushbulletPayload {
|
interface PushbulletPayload {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -50,7 +51,9 @@ class PushbulletAgent
|
|||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case Notification.MEDIA_PENDING:
|
case Notification.MEDIA_PENDING:
|
||||||
messageTitle = 'New Request';
|
messageTitle = `New ${
|
||||||
|
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||||
|
} Request`;
|
||||||
message += `${title}`;
|
message += `${title}`;
|
||||||
if (plot) {
|
if (plot) {
|
||||||
message += `\n\n${plot}`;
|
message += `\n\n${plot}`;
|
||||||
@@ -59,7 +62,20 @@ class PushbulletAgent
|
|||||||
message += `\nStatus: Pending Approval`;
|
message += `\nStatus: Pending Approval`;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_APPROVED:
|
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}`;
|
message += `${title}`;
|
||||||
if (plot) {
|
if (plot) {
|
||||||
message += `\n\n${plot}`;
|
message += `\n\n${plot}`;
|
||||||
@@ -68,7 +84,9 @@ class PushbulletAgent
|
|||||||
message += `\nStatus: Processing`;
|
message += `\nStatus: Processing`;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_AVAILABLE:
|
case Notification.MEDIA_AVAILABLE:
|
||||||
messageTitle = 'Now Available';
|
messageTitle = `${
|
||||||
|
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||||
|
} Now Available`;
|
||||||
message += `${title}`;
|
message += `${title}`;
|
||||||
if (plot) {
|
if (plot) {
|
||||||
message += `\n\n${plot}`;
|
message += `\n\n${plot}`;
|
||||||
@@ -77,7 +95,9 @@ class PushbulletAgent
|
|||||||
message += `\nStatus: Available`;
|
message += `\nStatus: Available`;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_DECLINED:
|
case Notification.MEDIA_DECLINED:
|
||||||
messageTitle = 'Request Declined';
|
messageTitle = `${
|
||||||
|
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||||
|
} Request Declined`;
|
||||||
message += `${title}`;
|
message += `${title}`;
|
||||||
if (plot) {
|
if (plot) {
|
||||||
message += `\n\n${plot}`;
|
message += `\n\n${plot}`;
|
||||||
@@ -86,7 +106,9 @@ class PushbulletAgent
|
|||||||
message += `\nStatus: Declined`;
|
message += `\nStatus: Declined`;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_FAILED:
|
case Notification.MEDIA_FAILED:
|
||||||
messageTitle = 'Failed Request';
|
messageTitle = `Failed ${
|
||||||
|
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||||
|
} Request`;
|
||||||
message += `${title}`;
|
message += `${title}`;
|
||||||
if (plot) {
|
if (plot) {
|
||||||
message += `\n\n${plot}`;
|
message += `\n\n${plot}`;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { hasNotificationType, Notification } from '..';
|
|||||||
import logger from '../../../logger';
|
import logger from '../../../logger';
|
||||||
import { getSettings, NotificationAgentPushover } from '../../settings';
|
import { getSettings, NotificationAgentPushover } from '../../settings';
|
||||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||||
|
import { MediaType } from '../../../constants/media';
|
||||||
|
|
||||||
interface PushoverPayload {
|
interface PushoverPayload {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -64,7 +65,9 @@ class PushoverAgent
|
|||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case Notification.MEDIA_PENDING:
|
case Notification.MEDIA_PENDING:
|
||||||
messageTitle = 'New Request';
|
messageTitle = `New ${
|
||||||
|
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||||
|
} Request`;
|
||||||
message += `<b>${title}</b>`;
|
message += `<b>${title}</b>`;
|
||||||
if (plot) {
|
if (plot) {
|
||||||
message += `\n${plot}`;
|
message += `\n${plot}`;
|
||||||
@@ -73,7 +76,20 @@ class PushoverAgent
|
|||||||
message += `\n\n<b>Status</b>\nPending Approval`;
|
message += `\n\n<b>Status</b>\nPending Approval`;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_APPROVED:
|
case Notification.MEDIA_APPROVED:
|
||||||
messageTitle = 'Request Approved';
|
messageTitle = `${
|
||||||
|
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||||
|
} Request Approved`;
|
||||||
|
message += `<b>${title}</b>`;
|
||||||
|
if (plot) {
|
||||||
|
message += `\n${plot}`;
|
||||||
|
}
|
||||||
|
message += `\n\n<b>Requested By</b>\n${username}`;
|
||||||
|
message += `\n\n<b>Status</b>\nProcessing`;
|
||||||
|
break;
|
||||||
|
case Notification.MEDIA_AUTO_APPROVED:
|
||||||
|
messageTitle = `${
|
||||||
|
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||||
|
} Request Automatically Approved`;
|
||||||
message += `<b>${title}</b>`;
|
message += `<b>${title}</b>`;
|
||||||
if (plot) {
|
if (plot) {
|
||||||
message += `\n${plot}`;
|
message += `\n${plot}`;
|
||||||
@@ -82,7 +98,9 @@ class PushoverAgent
|
|||||||
message += `\n\n<b>Status</b>\nProcessing`;
|
message += `\n\n<b>Status</b>\nProcessing`;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_AVAILABLE:
|
case Notification.MEDIA_AVAILABLE:
|
||||||
messageTitle = 'Now Available';
|
messageTitle = `${
|
||||||
|
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||||
|
} Now Available`;
|
||||||
message += `<b>${title}</b>`;
|
message += `<b>${title}</b>`;
|
||||||
if (plot) {
|
if (plot) {
|
||||||
message += `\n${plot}`;
|
message += `\n${plot}`;
|
||||||
@@ -91,7 +109,9 @@ class PushoverAgent
|
|||||||
message += `\n\n<b>Status</b>\nAvailable`;
|
message += `\n\n<b>Status</b>\nAvailable`;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_DECLINED:
|
case Notification.MEDIA_DECLINED:
|
||||||
messageTitle = 'Request Declined';
|
messageTitle = `${
|
||||||
|
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||||
|
} Request Declined`;
|
||||||
message += `<b>${title}</b>`;
|
message += `<b>${title}</b>`;
|
||||||
if (plot) {
|
if (plot) {
|
||||||
message += `\n${plot}`;
|
message += `\n${plot}`;
|
||||||
@@ -101,7 +121,9 @@ class PushoverAgent
|
|||||||
priority = 1;
|
priority = 1;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_FAILED:
|
case Notification.MEDIA_FAILED:
|
||||||
messageTitle = 'Failed Request';
|
messageTitle = `Failed ${
|
||||||
|
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||||
|
} Request`;
|
||||||
message += `<b>${title}</b>`;
|
message += `<b>${title}</b>`;
|
||||||
if (plot) {
|
if (plot) {
|
||||||
message += `\n${plot}`;
|
message += `\n${plot}`;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { hasNotificationType, Notification } from '..';
|
|||||||
import logger from '../../../logger';
|
import logger from '../../../logger';
|
||||||
import { getSettings, NotificationAgentSlack } from '../../settings';
|
import { getSettings, NotificationAgentSlack } from '../../settings';
|
||||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||||
|
import { MediaType } from '../../../constants/media';
|
||||||
|
|
||||||
interface EmbedField {
|
interface EmbedField {
|
||||||
type: 'plain_text' | 'mrkdwn';
|
type: 'plain_text' | 'mrkdwn';
|
||||||
@@ -72,35 +73,54 @@ class SlackAgent
|
|||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case Notification.MEDIA_PENDING:
|
case Notification.MEDIA_PENDING:
|
||||||
header = 'New Request';
|
header = `New ${
|
||||||
|
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||||
|
} Request`;
|
||||||
fields.push({
|
fields.push({
|
||||||
type: 'mrkdwn',
|
type: 'mrkdwn',
|
||||||
text: '*Status*\nPending Approval',
|
text: '*Status*\nPending Approval',
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_APPROVED:
|
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({
|
fields.push({
|
||||||
type: 'mrkdwn',
|
type: 'mrkdwn',
|
||||||
text: '*Status*\nProcessing',
|
text: '*Status*\nProcessing',
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_AVAILABLE:
|
case Notification.MEDIA_AVAILABLE:
|
||||||
header = 'Now Available';
|
header = `${
|
||||||
|
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||||
|
} Now Available`;
|
||||||
fields.push({
|
fields.push({
|
||||||
type: 'mrkdwn',
|
type: 'mrkdwn',
|
||||||
text: '*Status*\nAvailable',
|
text: '*Status*\nAvailable',
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_DECLINED:
|
case Notification.MEDIA_DECLINED:
|
||||||
header = 'Request Declined';
|
header = `${
|
||||||
|
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||||
|
} Request Declined`;
|
||||||
fields.push({
|
fields.push({
|
||||||
type: 'mrkdwn',
|
type: 'mrkdwn',
|
||||||
text: '*Status*\nDeclined',
|
text: '*Status*\nDeclined',
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_FAILED:
|
case Notification.MEDIA_FAILED:
|
||||||
header = 'Failed Request';
|
header = `Failed ${
|
||||||
|
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||||
|
} Request`;
|
||||||
fields.push({
|
fields.push({
|
||||||
type: 'mrkdwn',
|
type: 'mrkdwn',
|
||||||
text: '*Status*\nFailed',
|
text: '*Status*\nFailed',
|
||||||
@@ -206,7 +226,7 @@ class SlackAgent
|
|||||||
type: Notification,
|
type: Notification,
|
||||||
payload: NotificationPayload
|
payload: NotificationPayload
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
logger.debug('Sending slack notification', { label: 'Notifications' });
|
logger.debug('Sending Slack notification', { label: 'Notifications' });
|
||||||
try {
|
try {
|
||||||
const webhookUrl = this.getSettings().options.webhookUrl;
|
const webhookUrl = this.getSettings().options.webhookUrl;
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,24 @@ import axios from 'axios';
|
|||||||
import { hasNotificationType, Notification } from '..';
|
import { hasNotificationType, Notification } from '..';
|
||||||
import logger from '../../../logger';
|
import logger from '../../../logger';
|
||||||
import { getSettings, NotificationAgentTelegram } from '../../settings';
|
import { getSettings, NotificationAgentTelegram } from '../../settings';
|
||||||
|
import { MediaType } from '../../../constants/media';
|
||||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||||
|
|
||||||
interface TelegramPayload {
|
interface TelegramMessagePayload {
|
||||||
text: string;
|
text: string;
|
||||||
parse_mode: string;
|
parse_mode: string;
|
||||||
chat_id: string;
|
chat_id: string;
|
||||||
disable_notification: boolean;
|
disable_notification: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TelegramPhotoPayload {
|
||||||
|
photo: string;
|
||||||
|
caption: string;
|
||||||
|
parse_mode: string;
|
||||||
|
chat_id: string;
|
||||||
|
disable_notification: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
class TelegramAgent
|
class TelegramAgent
|
||||||
extends BaseAgent<NotificationAgentTelegram>
|
extends BaseAgent<NotificationAgentTelegram>
|
||||||
implements NotificationAgent {
|
implements NotificationAgent {
|
||||||
@@ -58,7 +67,9 @@ class TelegramAgent
|
|||||||
/* eslint-disable no-useless-escape */
|
/* eslint-disable no-useless-escape */
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case Notification.MEDIA_PENDING:
|
case Notification.MEDIA_PENDING:
|
||||||
message += `\*New Request\*`;
|
message += `\*New ${
|
||||||
|
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||||
|
} Request\*`;
|
||||||
message += `\n\n\*${title}\*`;
|
message += `\n\n\*${title}\*`;
|
||||||
if (plot) {
|
if (plot) {
|
||||||
message += `\n${plot}`;
|
message += `\n${plot}`;
|
||||||
@@ -67,7 +78,20 @@ class TelegramAgent
|
|||||||
message += `\n\n\*Status\*\nPending Approval`;
|
message += `\n\n\*Status\*\nPending Approval`;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_APPROVED:
|
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}\*`;
|
message += `\n\n\*${title}\*`;
|
||||||
if (plot) {
|
if (plot) {
|
||||||
message += `\n${plot}`;
|
message += `\n${plot}`;
|
||||||
@@ -76,7 +100,9 @@ class TelegramAgent
|
|||||||
message += `\n\n\*Status\*\nProcessing`;
|
message += `\n\n\*Status\*\nProcessing`;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_AVAILABLE:
|
case Notification.MEDIA_AVAILABLE:
|
||||||
message += `\*Now Available\*`;
|
message += `\*${
|
||||||
|
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||||
|
} Now Available\*`;
|
||||||
message += `\n\n\*${title}\*`;
|
message += `\n\n\*${title}\*`;
|
||||||
if (plot) {
|
if (plot) {
|
||||||
message += `\n${plot}`;
|
message += `\n${plot}`;
|
||||||
@@ -85,7 +111,9 @@ class TelegramAgent
|
|||||||
message += `\n\n\*Status\*\nAvailable`;
|
message += `\n\n\*Status\*\nAvailable`;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_DECLINED:
|
case Notification.MEDIA_DECLINED:
|
||||||
message += `\*Request Declined\*`;
|
message += `\*${
|
||||||
|
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||||
|
} Request Declined\*`;
|
||||||
message += `\n\n\*${title}\*`;
|
message += `\n\n\*${title}\*`;
|
||||||
if (plot) {
|
if (plot) {
|
||||||
message += `\n${plot}`;
|
message += `\n${plot}`;
|
||||||
@@ -94,7 +122,9 @@ class TelegramAgent
|
|||||||
message += `\n\n\*Status\*\nDeclined`;
|
message += `\n\n\*Status\*\nDeclined`;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_FAILED:
|
case Notification.MEDIA_FAILED:
|
||||||
message += `\*Failed Request\*`;
|
message += `\*Failed ${
|
||||||
|
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||||
|
} Request\*`;
|
||||||
message += `\n\n\*${title}\*`;
|
message += `\n\n\*${title}\*`;
|
||||||
if (plot) {
|
if (plot) {
|
||||||
message += `\n${plot}`;
|
message += `\n${plot}`;
|
||||||
@@ -121,18 +151,53 @@ class TelegramAgent
|
|||||||
type: Notification,
|
type: Notification,
|
||||||
payload: NotificationPayload
|
payload: NotificationPayload
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
logger.debug('Sending telegram notification', { label: 'Notifications' });
|
logger.debug('Sending Telegram notification', { label: 'Notifications' });
|
||||||
try {
|
try {
|
||||||
const endpoint = `${this.baseUrl}bot${
|
const endpoint = `${this.baseUrl}bot${
|
||||||
this.getSettings().options.botAPI
|
this.getSettings().options.botAPI
|
||||||
}/sendMessage`;
|
}/${payload.image ? 'sendPhoto' : 'sendMessage'}`;
|
||||||
|
|
||||||
await axios.post(endpoint, {
|
// 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),
|
text: this.buildMessage(type, payload),
|
||||||
parse_mode: 'MarkdownV2',
|
parse_mode: 'MarkdownV2',
|
||||||
chat_id: `${this.getSettings().options.chatId}`,
|
chat_id: `${this.getSettings().options.chatId}`,
|
||||||
disable_notification: this.getSettings().options.sendSilently,
|
disable_notification: this.getSettings().options.sendSilently,
|
||||||
} as TelegramPayload);
|
} 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;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
|
|||||||
notifyuser_email: 'notifyUser.email',
|
notifyuser_email: 'notifyUser.email',
|
||||||
notifyuser_avatar: 'notifyUser.avatar',
|
notifyuser_avatar: 'notifyUser.avatar',
|
||||||
notifyuser_settings_discordId: 'notifyUser.settings.discordId',
|
notifyuser_settings_discordId: 'notifyUser.settings.discordId',
|
||||||
|
notifyuser_settings_telegramChatId: 'notifyUser.settings.telegramChatId',
|
||||||
media_tmdbid: 'media.tmdbId',
|
media_tmdbid: 'media.tmdbId',
|
||||||
media_imdbid: 'media.imdbId',
|
media_imdbid: 'media.imdbId',
|
||||||
media_tvdbid: 'media.tvdbId',
|
media_tvdbid: 'media.tvdbId',
|
||||||
@@ -137,7 +138,7 @@ class WebhookAgent
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error sending Webhook notification', {
|
logger.error('Error sending webhook notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export enum Notification {
|
|||||||
MEDIA_FAILED = 16,
|
MEDIA_FAILED = 16,
|
||||||
TEST_NOTIFICATION = 32,
|
TEST_NOTIFICATION = 32,
|
||||||
MEDIA_DECLINED = 64,
|
MEDIA_DECLINED = 64,
|
||||||
|
MEDIA_AUTO_APPROVED = 128,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const hasNotificationType = (
|
export const hasNotificationType = (
|
||||||
|
|||||||
616
server/lib/scanners/baseScanner.ts
Normal file
616
server/lib/scanners/baseScanner.ts
Normal file
@@ -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<T> {
|
||||||
|
run: () => Promise<void>;
|
||||||
|
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<T> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void>,
|
||||||
|
{
|
||||||
|
start = 0,
|
||||||
|
end = this.bundleSize,
|
||||||
|
sessionId,
|
||||||
|
}: {
|
||||||
|
start?: number;
|
||||||
|
end?: number;
|
||||||
|
sessionId?: string;
|
||||||
|
} = {}
|
||||||
|
): Promise<void> {
|
||||||
|
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<void>((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<void>,
|
||||||
|
items: T[]
|
||||||
|
) {
|
||||||
|
await Promise.all(
|
||||||
|
items.map(async (item) => {
|
||||||
|
await processFn(item);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected log(
|
||||||
|
message: string,
|
||||||
|
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
|
||||||
|
optional?: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
logger[level](message, { label: this.scannerName, ...optional });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BaseScanner;
|
||||||
463
server/lib/scanners/plex/index.ts
Normal file
463
server/lib/scanners/plex/index.ts
Normal file
@@ -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<PlexLibraryItem>
|
||||||
|
implements RunnableScanner<SyncStatus> {
|
||||||
|
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<void> {
|
||||||
|
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<MediaIds> {
|
||||||
|
const mediaIds: Partial<MediaIds> = {};
|
||||||
|
// 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);
|
||||||
94
server/lib/scanners/radarr/index.ts
Normal file
94
server/lib/scanners/radarr/index.ts
Normal file
@@ -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<RadarrMovie>
|
||||||
|
implements RunnableScanner<SyncStatus> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
134
server/lib/scanners/sonarr/index.ts
Normal file
134
server/lib/scanners/sonarr/index.ts
Normal file
@@ -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<SonarrSeries>
|
||||||
|
implements RunnableScanner<SyncStatus> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
@@ -95,6 +95,8 @@ export interface NotificationAgentConfig {
|
|||||||
}
|
}
|
||||||
export interface NotificationAgentDiscord extends NotificationAgentConfig {
|
export interface NotificationAgentDiscord extends NotificationAgentConfig {
|
||||||
options: {
|
options: {
|
||||||
|
botUsername?: string;
|
||||||
|
botAvatarUrl?: string;
|
||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -115,11 +117,14 @@ export interface NotificationAgentEmail extends NotificationAgentConfig {
|
|||||||
authPass?: string;
|
authPass?: string;
|
||||||
allowSelfSigned: boolean;
|
allowSelfSigned: boolean;
|
||||||
senderName: string;
|
senderName: string;
|
||||||
|
pgpPrivateKey?: string;
|
||||||
|
pgpPassword?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationAgentTelegram extends NotificationAgentConfig {
|
export interface NotificationAgentTelegram extends NotificationAgentConfig {
|
||||||
options: {
|
options: {
|
||||||
|
botUsername?: string;
|
||||||
botAPI: string;
|
botAPI: string;
|
||||||
chatId: string;
|
chatId: string;
|
||||||
sendSilently: boolean;
|
sendSilently: boolean;
|
||||||
@@ -160,7 +165,6 @@ interface NotificationAgents {
|
|||||||
|
|
||||||
interface NotificationSettings {
|
interface NotificationSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
autoapprovalEnabled: boolean;
|
|
||||||
agents: NotificationAgents;
|
agents: NotificationAgents;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +214,6 @@ class Settings {
|
|||||||
},
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
autoapprovalEnabled: false,
|
|
||||||
agents: {
|
agents: {
|
||||||
email: {
|
email: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -228,6 +231,8 @@ class Settings {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
types: 0,
|
types: 0,
|
||||||
options: {
|
options: {
|
||||||
|
botUsername: '',
|
||||||
|
botAvatarUrl: '',
|
||||||
webhookUrl: '',
|
webhookUrl: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -242,6 +247,7 @@ class Settings {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
types: 0,
|
types: 0,
|
||||||
options: {
|
options: {
|
||||||
|
botUsername: '',
|
||||||
botAPI: '',
|
botAPI: '',
|
||||||
chatId: '',
|
chatId: '',
|
||||||
sendSilently: false,
|
sendSilently: false,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddTelegramSettingsToUserSettings1614334195680
|
||||||
|
implements MigrationInterface {
|
||||||
|
name = 'AddTelegramSettingsToUserSettings1614334195680';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
server/migration/1615333940450-AddPGPToUserSettings.ts
Normal file
31
server/migration/1615333940450-AddPGPToUserSettings.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddPGPToUserSettings1615333940450 implements MigrationInterface {
|
||||||
|
name = 'AddPGPToUserSettings1615333940450';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
TmdbMovieDetails,
|
TmdbMovieDetails,
|
||||||
TmdbMovieReleaseResult,
|
TmdbMovieReleaseResult,
|
||||||
|
TmdbProductionCompany,
|
||||||
} from '../api/themoviedb/interfaces';
|
} from '../api/themoviedb/interfaces';
|
||||||
import {
|
import {
|
||||||
ProductionCompany,
|
ProductionCompany,
|
||||||
@@ -79,6 +80,18 @@ export interface MovieDetails {
|
|||||||
plexUrl?: string;
|
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 = (
|
export const mapMovieDetails = (
|
||||||
movie: TmdbMovieDetails,
|
movie: TmdbMovieDetails,
|
||||||
media?: Media
|
media?: Media
|
||||||
@@ -91,12 +104,7 @@ export const mapMovieDetails = (
|
|||||||
originalLanguage: movie.original_language,
|
originalLanguage: movie.original_language,
|
||||||
originalTitle: movie.original_title,
|
originalTitle: movie.original_title,
|
||||||
popularity: movie.popularity,
|
popularity: movie.popularity,
|
||||||
productionCompanies: movie.production_companies.map((company) => ({
|
productionCompanies: movie.production_companies.map(mapProductionCompany),
|
||||||
id: company.id,
|
|
||||||
logoPath: company.logo_path,
|
|
||||||
originCountry: company.origin_country,
|
|
||||||
name: company.name,
|
|
||||||
})),
|
|
||||||
productionCountries: movie.production_countries,
|
productionCountries: movie.production_countries,
|
||||||
releaseDate: movie.release_date,
|
releaseDate: movie.release_date,
|
||||||
releases: movie.release_dates,
|
releases: movie.release_dates,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
mapExternalIds,
|
mapExternalIds,
|
||||||
Keyword,
|
Keyword,
|
||||||
mapVideos,
|
mapVideos,
|
||||||
|
TvNetwork,
|
||||||
} from './common';
|
} from './common';
|
||||||
import type {
|
import type {
|
||||||
TmdbTvEpisodeResult,
|
TmdbTvEpisodeResult,
|
||||||
@@ -16,6 +17,7 @@ import type {
|
|||||||
TmdbTvDetails,
|
TmdbTvDetails,
|
||||||
TmdbSeasonWithEpisodes,
|
TmdbSeasonWithEpisodes,
|
||||||
TmdbTvRatingResult,
|
TmdbTvRatingResult,
|
||||||
|
TmdbNetwork,
|
||||||
} from '../api/themoviedb/interfaces';
|
} from '../api/themoviedb/interfaces';
|
||||||
import type Media from '../entity/Media';
|
import type Media from '../entity/Media';
|
||||||
import { Video } from './Movie';
|
import { Video } from './Movie';
|
||||||
@@ -77,7 +79,7 @@ export interface TvDetails {
|
|||||||
lastEpisodeToAir?: Episode;
|
lastEpisodeToAir?: Episode;
|
||||||
name: string;
|
name: string;
|
||||||
nextEpisodeToAir?: Episode;
|
nextEpisodeToAir?: Episode;
|
||||||
networks: ProductionCompany[];
|
networks: TvNetwork[];
|
||||||
numberOfEpisodes: number;
|
numberOfEpisodes: number;
|
||||||
numberOfSeasons: number;
|
numberOfSeasons: number;
|
||||||
originCountry: string[];
|
originCountry: string[];
|
||||||
@@ -89,6 +91,7 @@ export interface TvDetails {
|
|||||||
spokenLanguages: SpokenLanguage[];
|
spokenLanguages: SpokenLanguage[];
|
||||||
seasons: Season[];
|
seasons: Season[];
|
||||||
status: string;
|
status: string;
|
||||||
|
tagline?: string;
|
||||||
type: string;
|
type: string;
|
||||||
voteAverage: number;
|
voteAverage: number;
|
||||||
voteCount: number;
|
voteCount: number;
|
||||||
@@ -139,6 +142,15 @@ export const mapSeasonWithEpisodes = (
|
|||||||
posterPath: season.poster_path,
|
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 = (
|
export const mapTvDetails = (
|
||||||
show: TmdbTvDetails,
|
show: TmdbTvDetails,
|
||||||
media?: Media
|
media?: Media
|
||||||
@@ -157,17 +169,13 @@ export const mapTvDetails = (
|
|||||||
languages: show.languages,
|
languages: show.languages,
|
||||||
lastAirDate: show.last_air_date,
|
lastAirDate: show.last_air_date,
|
||||||
name: show.name,
|
name: show.name,
|
||||||
networks: show.networks.map((network) => ({
|
networks: show.networks.map(mapNetwork),
|
||||||
id: network.id,
|
|
||||||
name: network.name,
|
|
||||||
originCountry: network.origin_country,
|
|
||||||
logoPath: network.logo_path,
|
|
||||||
})),
|
|
||||||
numberOfEpisodes: show.number_of_episodes,
|
numberOfEpisodes: show.number_of_episodes,
|
||||||
numberOfSeasons: show.number_of_seasons,
|
numberOfSeasons: show.number_of_seasons,
|
||||||
originCountry: show.origin_country,
|
originCountry: show.origin_country,
|
||||||
originalLanguage: show.original_language,
|
originalLanguage: show.original_language,
|
||||||
originalName: show.original_name,
|
originalName: show.original_name,
|
||||||
|
tagline: show.tagline,
|
||||||
overview: show.overview,
|
overview: show.overview,
|
||||||
popularity: show.popularity,
|
popularity: show.popularity,
|
||||||
productionCompanies: show.production_companies.map((company) => ({
|
productionCompanies: show.production_companies.map((company) => ({
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ export interface ProductionCompany {
|
|||||||
logoPath?: string;
|
logoPath?: string;
|
||||||
originCountry: string;
|
originCountry: string;
|
||||||
name: 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 {
|
export interface Keyword {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { isMovie, isPerson } from '../utils/typeHelpers';
|
|||||||
import { MediaType } from '../constants/media';
|
import { MediaType } from '../constants/media';
|
||||||
import { getSettings } from '../lib/settings';
|
import { getSettings } from '../lib/settings';
|
||||||
import { User } from '../entity/User';
|
import { User } from '../entity/User';
|
||||||
|
import { mapProductionCompany } from '../models/Movie';
|
||||||
|
import { mapNetwork } from '../models/Tv';
|
||||||
|
|
||||||
const createTmdbWithRegionLanaguage = (user?: User): TheMovieDb => {
|
const createTmdbWithRegionLanaguage = (user?: User): TheMovieDb => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
@@ -38,6 +40,8 @@ discoverRoutes.get('/movies', async (req, res) => {
|
|||||||
const data = await tmdb.getDiscoverMovies({
|
const data = await tmdb.getDiscoverMovies({
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.query.language as string,
|
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(
|
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) => {
|
discoverRoutes.get('/movies/upcoming', async (req, res) => {
|
||||||
const tmdb = createTmdbWithRegionLanaguage(req.user);
|
const tmdb = createTmdbWithRegionLanaguage(req.user);
|
||||||
|
|
||||||
@@ -99,6 +230,8 @@ discoverRoutes.get('/tv', async (req, res) => {
|
|||||||
const data = await tmdb.getDiscoverTv({
|
const data = await tmdb.getDiscoverTv({
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.query.language as string,
|
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(
|
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) => {
|
discoverRoutes.get('/tv/upcoming', async (req, res) => {
|
||||||
const tmdb = createTmdbWithRegionLanaguage(req.user);
|
const tmdb = createTmdbWithRegionLanaguage(req.user);
|
||||||
|
|
||||||
@@ -175,15 +433,18 @@ discoverRoutes.get('/trending', async (req, res) => {
|
|||||||
? mapMovieResult(
|
? mapMovieResult(
|
||||||
result,
|
result,
|
||||||
media.find(
|
media.find(
|
||||||
(req) =>
|
(med) =>
|
||||||
req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
|
med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
: isPerson(result)
|
: isPerson(result)
|
||||||
? mapPersonResult(result)
|
? mapPersonResult(result)
|
||||||
: mapTvResult(
|
: mapTvResult(
|
||||||
result,
|
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(
|
mapMovieResult(
|
||||||
result,
|
result,
|
||||||
media.find(
|
media.find(
|
||||||
(req) =>
|
(med) =>
|
||||||
req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
|
med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { getAppVersion, getCommitTag } from '../utils/appVersion';
|
|||||||
import serviceRoutes from './service';
|
import serviceRoutes from './service';
|
||||||
import { appDataStatus, appDataPath } from '../utils/appDataVolume';
|
import { appDataStatus, appDataPath } from '../utils/appDataVolume';
|
||||||
import TheMovieDb from '../api/themoviedb';
|
import TheMovieDb from '../api/themoviedb';
|
||||||
|
import { mapProductionCompany } from '../models/Movie';
|
||||||
|
import { mapNetwork } from '../models/Tv';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -74,6 +76,42 @@ router.get('/languages', isAuthenticated(), async (req, res) => {
|
|||||||
return res.status(200).json(languages);
|
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) => {
|
router.get('/', (_req, res) => {
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
api: 'Overseerr API',
|
api: 'Overseerr API',
|
||||||
|
|||||||
@@ -211,29 +211,31 @@ requestRoutes.post(
|
|||||||
media,
|
media,
|
||||||
requestedBy: requestUser,
|
requestedBy: requestUser,
|
||||||
// If the user is an admin or has the "auto approve" permission, automatically approve the request
|
// If the user is an admin or has the "auto approve" permission, automatically approve the request
|
||||||
status:
|
status: req.user?.hasPermission(
|
||||||
req.user?.hasPermission(
|
[
|
||||||
req.body.is4k
|
req.body.is4k
|
||||||
? Permission.AUTO_APPROVE_4K
|
? Permission.AUTO_APPROVE_4K
|
||||||
: Permission.AUTO_APPROVE
|
: Permission.AUTO_APPROVE,
|
||||||
) ||
|
|
||||||
req.user?.hasPermission(
|
|
||||||
req.body.is4k
|
req.body.is4k
|
||||||
? Permission.AUTO_APPROVE_4K_MOVIE
|
? Permission.AUTO_APPROVE_4K_MOVIE
|
||||||
: Permission.AUTO_APPROVE_MOVIE
|
: Permission.AUTO_APPROVE_MOVIE,
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
],
|
||||||
|
{ type: 'or' }
|
||||||
)
|
)
|
||||||
? MediaRequestStatus.APPROVED
|
? MediaRequestStatus.APPROVED
|
||||||
: MediaRequestStatus.PENDING,
|
: MediaRequestStatus.PENDING,
|
||||||
modifiedBy:
|
modifiedBy: req.user?.hasPermission(
|
||||||
req.user?.hasPermission(
|
[
|
||||||
req.body.is4k
|
req.body.is4k
|
||||||
? Permission.AUTO_APPROVE_4K
|
? Permission.AUTO_APPROVE_4K
|
||||||
: Permission.AUTO_APPROVE
|
: Permission.AUTO_APPROVE,
|
||||||
) ||
|
|
||||||
req.user?.hasPermission(
|
|
||||||
req.body.is4k
|
req.body.is4k
|
||||||
? Permission.AUTO_APPROVE_4K_MOVIE
|
? Permission.AUTO_APPROVE_4K_MOVIE
|
||||||
: Permission.AUTO_APPROVE_MOVIE
|
: Permission.AUTO_APPROVE_MOVIE,
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
],
|
||||||
|
{ type: 'or' }
|
||||||
)
|
)
|
||||||
? req.user
|
? req.user
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -286,29 +288,31 @@ requestRoutes.post(
|
|||||||
media,
|
media,
|
||||||
requestedBy: requestUser,
|
requestedBy: requestUser,
|
||||||
// If the user is an admin or has the "auto approve" permission, automatically approve the request
|
// If the user is an admin or has the "auto approve" permission, automatically approve the request
|
||||||
status:
|
status: req.user?.hasPermission(
|
||||||
req.user?.hasPermission(
|
[
|
||||||
req.body.is4k
|
req.body.is4k
|
||||||
? Permission.AUTO_APPROVE_4K
|
? Permission.AUTO_APPROVE_4K
|
||||||
: Permission.AUTO_APPROVE
|
: Permission.AUTO_APPROVE,
|
||||||
) ||
|
|
||||||
req.user?.hasPermission(
|
|
||||||
req.body.is4k
|
req.body.is4k
|
||||||
? Permission.AUTO_APPROVE_4K_TV
|
? Permission.AUTO_APPROVE_4K_TV
|
||||||
: Permission.AUTO_APPROVE_TV
|
: Permission.AUTO_APPROVE_TV,
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
],
|
||||||
|
{ type: 'or' }
|
||||||
)
|
)
|
||||||
? MediaRequestStatus.APPROVED
|
? MediaRequestStatus.APPROVED
|
||||||
: MediaRequestStatus.PENDING,
|
: MediaRequestStatus.PENDING,
|
||||||
modifiedBy:
|
modifiedBy: req.user?.hasPermission(
|
||||||
req.user?.hasPermission(
|
[
|
||||||
req.body.is4k
|
req.body.is4k
|
||||||
? Permission.AUTO_APPROVE_4K
|
? Permission.AUTO_APPROVE_4K
|
||||||
: Permission.AUTO_APPROVE
|
: Permission.AUTO_APPROVE,
|
||||||
) ||
|
|
||||||
req.user?.hasPermission(
|
|
||||||
req.body.is4k
|
req.body.is4k
|
||||||
? Permission.AUTO_APPROVE_4K_TV
|
? Permission.AUTO_APPROVE_4K_TV
|
||||||
: Permission.AUTO_APPROVE_TV
|
: Permission.AUTO_APPROVE_TV,
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
],
|
||||||
|
{ type: 'or' }
|
||||||
)
|
)
|
||||||
? req.user
|
? req.user
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -321,16 +325,17 @@ requestRoutes.post(
|
|||||||
(sn) =>
|
(sn) =>
|
||||||
new SeasonRequest({
|
new SeasonRequest({
|
||||||
seasonNumber: sn,
|
seasonNumber: sn,
|
||||||
status:
|
status: req.user?.hasPermission(
|
||||||
req.user?.hasPermission(
|
[
|
||||||
req.body.is4k
|
req.body.is4k
|
||||||
? Permission.AUTO_APPROVE_4K
|
? Permission.AUTO_APPROVE_4K
|
||||||
: Permission.AUTO_APPROVE
|
: Permission.AUTO_APPROVE,
|
||||||
) ||
|
|
||||||
req.user?.hasPermission(
|
|
||||||
req.body.is4k
|
req.body.is4k
|
||||||
? Permission.AUTO_APPROVE_4K_TV
|
? Permission.AUTO_APPROVE_4K_TV
|
||||||
: Permission.AUTO_APPROVE_TV
|
: Permission.AUTO_APPROVE_TV,
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
],
|
||||||
|
{ type: 'or' }
|
||||||
)
|
)
|
||||||
? MediaRequestStatus.APPROVED
|
? MediaRequestStatus.APPROVED
|
||||||
: MediaRequestStatus.PENDING,
|
: MediaRequestStatus.PENDING,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { getRepository } from 'typeorm';
|
|||||||
import { User } from '../../entity/User';
|
import { User } from '../../entity/User';
|
||||||
import PlexAPI from '../../api/plexapi';
|
import PlexAPI from '../../api/plexapi';
|
||||||
import PlexTvAPI from '../../api/plextv';
|
import PlexTvAPI from '../../api/plextv';
|
||||||
import { jobPlexFullSync } from '../../job/plexsync';
|
|
||||||
import { scheduledJobs } from '../../job/schedule';
|
import { scheduledJobs } from '../../job/schedule';
|
||||||
import { Permission } from '../../lib/permissions';
|
import { Permission } from '../../lib/permissions';
|
||||||
import { isAuthenticated } from '../../middleware/auth';
|
import { isAuthenticated } from '../../middleware/auth';
|
||||||
@@ -17,6 +16,7 @@ import notificationRoutes from './notifications';
|
|||||||
import sonarrRoutes from './sonarr';
|
import sonarrRoutes from './sonarr';
|
||||||
import radarrRoutes from './radarr';
|
import radarrRoutes from './radarr';
|
||||||
import cacheManager, { AvailableCacheIds } from '../../lib/cache';
|
import cacheManager, { AvailableCacheIds } from '../../lib/cache';
|
||||||
|
import { plexFullScanner } from '../../lib/scanners/plex';
|
||||||
|
|
||||||
const settingsRoutes = Router();
|
const settingsRoutes = Router();
|
||||||
|
|
||||||
@@ -211,16 +211,16 @@ settingsRoutes.get('/plex/library', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
settingsRoutes.get('/plex/sync', (_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) => {
|
settingsRoutes.post('/plex/sync', (req, res) => {
|
||||||
if (req.body.cancel) {
|
if (req.body.cancel) {
|
||||||
jobPlexFullSync.cancel();
|
plexFullScanner.cancel();
|
||||||
} else if (req.body.start) {
|
} 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) => {
|
settingsRoutes.get('/jobs', (_req, res) => {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ notificationRoutes.get('/', (_req, res) => {
|
|||||||
const settings = getSettings().notifications;
|
const settings = getSettings().notifications;
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
enabled: settings.enabled,
|
enabled: settings.enabled,
|
||||||
autoapprovalEnabled: settings.autoapprovalEnabled,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -24,13 +23,11 @@ notificationRoutes.post('/', (req, res) => {
|
|||||||
|
|
||||||
Object.assign(settings.notifications, {
|
Object.assign(settings.notifications, {
|
||||||
enabled: req.body.enabled,
|
enabled: req.body.enabled,
|
||||||
autoapprovalEnabled: req.body.autoapprovalEnabled,
|
|
||||||
});
|
});
|
||||||
settings.save();
|
settings.save();
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
enabled: settings.notifications.enabled,
|
enabled: settings.notifications.enabled,
|
||||||
autoapprovalEnabled: settings.notifications.autoapprovalEnabled,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// Only let the owner grant admin privileges
|
||||||
!(hasPermission(Permission.ADMIN, permissions) && user?.id !== 1) ||
|
!(hasPermission(Permission.ADMIN, permissions) && user?.id !== 1) ||
|
||||||
// Only let users with the manage settings permission, grant the same permission
|
// Only let users with the manage settings permission, grant the same permission
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { getRepository } from 'typeorm';
|
import { getRepository } from 'typeorm';
|
||||||
|
import { canMakePermissionsChange } from '.';
|
||||||
import { User } from '../../entity/User';
|
import { User } from '../../entity/User';
|
||||||
|
import { getSettings } from '../../lib/settings';
|
||||||
import { UserSettings } from '../../entity/UserSettings';
|
import { UserSettings } from '../../entity/UserSettings';
|
||||||
import {
|
import {
|
||||||
UserSettingsGeneralResponse,
|
UserSettingsGeneralResponse,
|
||||||
@@ -21,6 +23,7 @@ const isOwnProfileOrAdmin = (): Middleware => {
|
|||||||
message: "You do not have permission to view this user's settings.",
|
message: "You do not have permission to view this user's settings.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
return authMiddleware;
|
return authMiddleware;
|
||||||
@@ -70,6 +73,14 @@ userSettingsRoutes.post<
|
|||||||
return next({ status: 404, message: 'User not found.' });
|
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;
|
user.username = req.body.username;
|
||||||
if (!user.settings) {
|
if (!user.settings) {
|
||||||
user.settings = new UserSettings({
|
user.settings = new UserSettings({
|
||||||
@@ -137,7 +148,19 @@ userSettingsRoutes.post<
|
|||||||
if (req.body.newPassword.length < 8) {
|
if (req.body.newPassword.length < 8) {
|
||||||
return next({
|
return next({
|
||||||
status: 400,
|
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(),
|
isOwnProfileOrAdmin(),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await userRepository.findOne({
|
const user = await userRepository.findOne({
|
||||||
@@ -196,7 +220,12 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
|
|||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
enableNotifications: user.settings?.enableNotifications ?? true,
|
enableNotifications: user.settings?.enableNotifications ?? true,
|
||||||
|
telegramBotUsername:
|
||||||
|
settings?.notifications.agents.telegram.options.botUsername,
|
||||||
discordId: user.settings?.discordId,
|
discordId: user.settings?.discordId,
|
||||||
|
telegramChatId: user.settings?.telegramChatId,
|
||||||
|
telegramSendSilently: user?.settings?.telegramSendSilently,
|
||||||
|
pgpKey: user?.settings?.pgpKey,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
next({ status: 500, message: e.message });
|
next({ status: 500, message: e.message });
|
||||||
@@ -220,15 +249,29 @@ userSettingsRoutes.post<
|
|||||||
return next({ status: 404, message: 'User not found.' });
|
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) {
|
if (!user.settings) {
|
||||||
user.settings = new UserSettings({
|
user.settings = new UserSettings({
|
||||||
user: req.user,
|
user: req.user,
|
||||||
enableNotifications: req.body.enableNotifications,
|
enableNotifications: req.body.enableNotifications,
|
||||||
discordId: req.body.discordId,
|
discordId: req.body.discordId,
|
||||||
|
telegramChatId: req.body.telegramChatId,
|
||||||
|
telegramSendSilently: req.body.telegramSendSilently,
|
||||||
|
pgpKey: req.body.pgpKey,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
user.settings.enableNotifications = req.body.enableNotifications;
|
user.settings.enableNotifications = req.body.enableNotifications;
|
||||||
user.settings.discordId = req.body.discordId;
|
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);
|
userRepository.save(user);
|
||||||
@@ -236,6 +279,9 @@ userSettingsRoutes.post<
|
|||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
enableNotifications: user.settings.enableNotifications,
|
enableNotifications: user.settings.enableNotifications,
|
||||||
discordId: user.settings.discordId,
|
discordId: user.settings.discordId,
|
||||||
|
telegramChatId: user.settings.telegramChatId,
|
||||||
|
telegramSendSilently: user.settings.telegramSendSilently,
|
||||||
|
pgpKey: user.settings.pgpKey,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
next({ status: 500, message: e.message });
|
next({ status: 500, message: e.message });
|
||||||
@@ -283,13 +329,20 @@ userSettingsRoutes.post<
|
|||||||
return next({ status: 404, message: 'User not found.' });
|
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({
|
return next({
|
||||||
status: 500,
|
status: 403,
|
||||||
message: 'Permissions for user with ID 1 cannot be modified',
|
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;
|
user.permissions = req.body.permissions;
|
||||||
|
|
||||||
await userRepository.save(user);
|
await userRepository.save(user);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ confinement: strict
|
|||||||
parts:
|
parts:
|
||||||
overseerr:
|
overseerr:
|
||||||
plugin: nodejs
|
plugin: nodejs
|
||||||
nodejs-version: "12.18.4"
|
nodejs-version: "14.16.0"
|
||||||
nodejs-package-manager: "yarn"
|
nodejs-package-manager: "yarn"
|
||||||
nodejs-yarn-version: v1.22.5
|
nodejs-yarn-version: v1.22.5
|
||||||
build-packages:
|
build-packages:
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ import Transition from '../Transition';
|
|||||||
import PageTitle from '../Common/PageTitle';
|
import PageTitle from '../Common/PageTitle';
|
||||||
import { useUser, Permission } from '../../hooks/useUser';
|
import { useUser, Permission } from '../../hooks/useUser';
|
||||||
import useSettings from '../../hooks/useSettings';
|
import useSettings from '../../hooks/useSettings';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { uniq } from 'lodash';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
overviewunavailable: 'Overview unavailable.',
|
overviewunavailable: 'Overview unavailable.',
|
||||||
overview: 'Overview',
|
overview: 'Overview',
|
||||||
movies: 'Movies',
|
movies: 'Movies',
|
||||||
numberofmovies: 'Number of Movies: {count}',
|
numberofmovies: '{count} Movies',
|
||||||
requesting: 'Requesting…',
|
requesting: 'Requesting…',
|
||||||
request: 'Request',
|
request: 'Request',
|
||||||
requestcollection: 'Request Collection',
|
requestcollection: 'Request Collection',
|
||||||
@@ -62,6 +64,10 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { data: genres } = useSWR<{ id: number; name: string }[]>(
|
||||||
|
`/api/v1/genres/movie?language=${locale}`
|
||||||
|
);
|
||||||
|
|
||||||
if (!data && !error) {
|
if (!data && !error) {
|
||||||
return <LoadingSpinner />;
|
return <LoadingSpinner />;
|
||||||
}
|
}
|
||||||
@@ -105,6 +111,17 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
|||||||
collectionStatus4k = MediaStatus.PARTIALLY_AVAILABLE;
|
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(
|
const requestableParts = data.parts.filter(
|
||||||
(part) =>
|
(part) =>
|
||||||
!part.mediaInfo ||
|
!part.mediaInfo ||
|
||||||
@@ -147,9 +164,43 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) => (
|
||||||
|
<Link
|
||||||
|
href={`/discover/movies/genre/${genreId}`}
|
||||||
|
key={`genre-${genreId}`}
|
||||||
|
>
|
||||||
|
<a className="hover:underline">
|
||||||
|
{genres.find((g) => g.id === genreId)?.name}
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
.reduce((prev, curr) => (
|
||||||
|
<>
|
||||||
|
{prev}, {curr}
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="px-4 pt-16 -mx-4 -mt-16 bg-center bg-cover"
|
className="media-page"
|
||||||
style={{
|
style={{
|
||||||
height: 493,
|
height: 493,
|
||||||
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
|
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
|
||||||
@@ -216,24 +267,20 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
|||||||
</ul>
|
</ul>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Transition>
|
</Transition>
|
||||||
<div className="flex flex-col items-center pt-4 lg:flex-row lg:items-end">
|
<div className="media-header">
|
||||||
<div className="lg:mr-4">
|
|
||||||
<img
|
<img
|
||||||
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
|
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-44 lg:w-52"
|
className="media-poster"
|
||||||
/>
|
/>
|
||||||
</div>
|
<div className="media-title">
|
||||||
<div className="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left">
|
<div className="media-status">
|
||||||
<div className="mb-2 space-x-2">
|
|
||||||
<span className="ml-2 lg:ml-0">
|
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
status={collectionStatus}
|
status={collectionStatus}
|
||||||
inProgress={data.parts.some(
|
inProgress={data.parts.some(
|
||||||
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
|
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</span>
|
|
||||||
{settings.currentSettings.movie4kEnabled &&
|
{settings.currentSettings.movie4kEnabled &&
|
||||||
hasPermission(
|
hasPermission(
|
||||||
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
|
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
|
||||||
@@ -241,7 +288,6 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
|||||||
type: 'or',
|
type: 'or',
|
||||||
}
|
}
|
||||||
) && (
|
) && (
|
||||||
<span>
|
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
status={collectionStatus4k}
|
status={collectionStatus4k}
|
||||||
is4k
|
is4k
|
||||||
@@ -250,31 +296,34 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
|||||||
(part.mediaInfo?.downloadStatus4k ?? []).length > 0
|
(part.mediaInfo?.downloadStatus4k ?? []).length > 0
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl md:text-4xl">{data.name}</h1>
|
<h1>{data.name}</h1>
|
||||||
<span className="mt-1 text-xs lg:text-base lg:mt-0">
|
<span className="media-attributes">
|
||||||
{intl.formatMessage(messages.numberofmovies, {
|
{collectionAttributes.length > 0 &&
|
||||||
count: data.parts.length,
|
collectionAttributes
|
||||||
})}
|
.map((t, k) => <span key={k}>{t}</span>)
|
||||||
|
.reduce((prev, curr) => (
|
||||||
|
<>
|
||||||
|
{prev} | {curr}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative z-10 flex flex-wrap justify-center flex-shrink-0 mt-4 sm:justify-end sm:flex-nowrap lg:mt-0">
|
<div className="media-actions">
|
||||||
{hasPermission(Permission.REQUEST) &&
|
{hasPermission(Permission.REQUEST) &&
|
||||||
(collectionStatus !== MediaStatus.AVAILABLE ||
|
(hasRequestable ||
|
||||||
(settings.currentSettings.movie4kEnabled &&
|
(settings.currentSettings.movie4kEnabled &&
|
||||||
hasPermission(
|
hasPermission(
|
||||||
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
|
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
|
||||||
{ type: 'or' }
|
{ type: 'or' }
|
||||||
) &&
|
) &&
|
||||||
collectionStatus4k !== MediaStatus.AVAILABLE)) && (
|
hasRequestable4k)) && (
|
||||||
<div className="mb-3 sm:mb-0">
|
|
||||||
<ButtonWithDropdown
|
<ButtonWithDropdown
|
||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRequestModal(true);
|
setRequestModal(true);
|
||||||
setIs4k(collectionStatus === MediaStatus.AVAILABLE);
|
setIs4k(!hasRequestable);
|
||||||
}}
|
}}
|
||||||
text={
|
text={
|
||||||
<>
|
<>
|
||||||
@@ -294,9 +343,9 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
|||||||
</svg>
|
</svg>
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
collectionStatus === MediaStatus.AVAILABLE
|
hasRequestable
|
||||||
? messages.requestcollection4k
|
? messages.requestcollection
|
||||||
: messages.requestcollection
|
: messages.requestcollection4k
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
@@ -307,8 +356,8 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
|||||||
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
|
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
|
||||||
{ type: 'or' }
|
{ type: 'or' }
|
||||||
) &&
|
) &&
|
||||||
collectionStatus !== MediaStatus.AVAILABLE &&
|
hasRequestable &&
|
||||||
collectionStatus4k !== MediaStatus.AVAILABLE && (
|
hasRequestable4k && (
|
||||||
<ButtonWithDropdown.Item
|
<ButtonWithDropdown.Item
|
||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -336,29 +385,24 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
|||||||
</ButtonWithDropdown.Item>
|
</ButtonWithDropdown.Item>
|
||||||
)}
|
)}
|
||||||
</ButtonWithDropdown>
|
</ButtonWithDropdown>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col pt-8 pb-4 text-white md:flex-row">
|
<div className="media-overview">
|
||||||
<div className="flex-1 md:mr-8">
|
<div className="flex-1">
|
||||||
<h2 className="text-xl md:text-2xl">
|
<h2>{intl.formatMessage(messages.overview)}</h2>
|
||||||
{intl.formatMessage(messages.overview)}
|
<p>
|
||||||
</h2>
|
|
||||||
<p className="pt-2 text-sm md:text-base">
|
|
||||||
{data.overview
|
{data.overview
|
||||||
? data.overview
|
? data.overview
|
||||||
: intl.formatMessage(messages.overviewunavailable)}
|
: intl.formatMessage(messages.overviewunavailable)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
<div className="slider-header">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="slider-title">
|
||||||
<div className="inline-flex items-center text-xl leading-7 text-white sm:text-2xl sm:leading-9 sm:truncate">
|
|
||||||
<span>{intl.formatMessage(messages.movies)}</span>
|
<span>{intl.formatMessage(messages.movies)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<Slider
|
<Slider
|
||||||
sliderKey="collection-movies"
|
sliderKey="collection-movies"
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
|
|||||||
@@ -45,37 +45,37 @@ function Button<P extends ElementTypes = 'button'>(
|
|||||||
ref?: React.Ref<Element<P>>
|
ref?: React.Ref<Element<P>>
|
||||||
): JSX.Element {
|
): JSX.Element {
|
||||||
const buttonStyle = [
|
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) {
|
switch (buttonType) {
|
||||||
case 'primary':
|
case 'primary':
|
||||||
buttonStyle.push(
|
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;
|
break;
|
||||||
case 'danger':
|
case 'danger':
|
||||||
buttonStyle.push(
|
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;
|
break;
|
||||||
case 'warning':
|
case 'warning':
|
||||||
buttonStyle.push(
|
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;
|
break;
|
||||||
case 'success':
|
case 'success':
|
||||||
buttonStyle.push(
|
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;
|
break;
|
||||||
case 'ghost':
|
case 'ghost':
|
||||||
buttonStyle.push(
|
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;
|
break;
|
||||||
default:
|
default:
|
||||||
buttonStyle.push(
|
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'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,24 +59,23 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
|
|||||||
useClickOutside(buttonRef, () => setIsOpen(false));
|
useClickOutside(buttonRef, () => setIsOpen(false));
|
||||||
|
|
||||||
const styleClasses = {
|
const styleClasses = {
|
||||||
mainButtonClasses: '',
|
mainButtonClasses: 'text-white border',
|
||||||
dropdownSideButtonClasses: '',
|
dropdownSideButtonClasses: 'border',
|
||||||
dropdownClasses: '',
|
dropdownClasses: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (buttonType) {
|
switch (buttonType) {
|
||||||
case 'ghost':
|
case 'ghost':
|
||||||
styleClasses.mainButtonClasses =
|
styleClasses.mainButtonClasses +=
|
||||||
'text-white bg-transparent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
|
' bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
|
||||||
styleClasses.dropdownSideButtonClasses =
|
styleClasses.dropdownSideButtonClasses = styleClasses.mainButtonClasses;
|
||||||
'bg-transparent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
|
|
||||||
styleClasses.dropdownClasses = 'bg-gray-700';
|
styleClasses.dropdownClasses = 'bg-gray-700';
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
styleClasses.mainButtonClasses =
|
styleClasses.mainButtonClasses +=
|
||||||
'text-white bg-indigo-600 hover:text-white hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue';
|
' 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 =
|
styleClasses.dropdownSideButtonClasses +=
|
||||||
'bg-indigo-700 border border-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue';
|
' bg-indigo-700 border-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue';
|
||||||
styleClasses.dropdownClasses = 'bg-indigo-600';
|
styleClasses.dropdownClasses = 'bg-indigo-600';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const ListView: React.FC<ListViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ul className="cardList">
|
<ul className="cardList">
|
||||||
{items?.map((title) => {
|
{items?.map((title, index) => {
|
||||||
let titleCard: React.ReactNode;
|
let titleCard: React.ReactNode;
|
||||||
|
|
||||||
switch (title.mediaType) {
|
switch (title.mediaType) {
|
||||||
@@ -90,7 +90,7 @@ const ListView: React.FC<ListViewProps> = ({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <li key={title.id}>{titleCard}</li>;
|
return <li key={`${title.id}-${index}`}>{titleCard}</li>;
|
||||||
})}
|
})}
|
||||||
{isLoading &&
|
{isLoading &&
|
||||||
!isReachingEnd &&
|
!isReachingEnd &&
|
||||||
|
|||||||
48
src/components/CompanyCard/index.tsx
Normal file
48
src/components/CompanyCard/index.tsx
Normal file
@@ -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<CompanyCardProps> = ({ image, url, name }) => {
|
||||||
|
const [isHovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={url}>
|
||||||
|
<a
|
||||||
|
className={`relative flex items-center justify-center h-32 w-64 sm:h-36 sm:w-72 p-8 shadow transition ease-in-out duration-150 cursor-pointer transform-gpu ring-1 ${
|
||||||
|
isHovered
|
||||||
|
? 'bg-gray-700 scale-105 ring-gray-500'
|
||||||
|
: 'bg-gray-800 scale-100 ring-gray-700'
|
||||||
|
} rounded-xl`}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setHovered(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
setHovered(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="link"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={image}
|
||||||
|
alt={name}
|
||||||
|
className="relative z-40 max-w-full max-h-full"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`absolute bottom-0 left-0 right-0 h-12 rounded-b-xl bg-gradient-to-t z-0 ${
|
||||||
|
isHovered ? 'from-gray-800' : 'from-gray-900'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CompanyCard;
|
||||||
62
src/components/Discover/DiscoverMovieGenre/index.tsx
Normal file
62
src/components/Discover/DiscoverMovieGenre/index.tsx
Normal file
@@ -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<MovieResult, { genre: { id: number; name: string } }>(
|
||||||
|
`/api/v1/discover/movies/genre/${router.query.genreId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <Error statusCode={500} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = isLoadingInitialData
|
||||||
|
? intl.formatMessage(globalMessages.loading)
|
||||||
|
: intl.formatMessage(messages.genreMovies, {
|
||||||
|
genre: firstResultData?.genre.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle title={title} />
|
||||||
|
<div className="mt-1 mb-5">
|
||||||
|
<Header>{title}</Header>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
items={titles}
|
||||||
|
isEmpty={isEmpty}
|
||||||
|
isLoading={
|
||||||
|
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||||
|
}
|
||||||
|
isReachingEnd={isReachingEnd}
|
||||||
|
onScrollBottom={fetchMore}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscoverMovieGenre;
|
||||||
71
src/components/Discover/DiscoverMovieLanguage/index.tsx
Normal file
71
src/components/Discover/DiscoverMovieLanguage/index.tsx
Normal file
@@ -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 <Error statusCode={500} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = isLoadingInitialData
|
||||||
|
? intl.formatMessage(globalMessages.loading)
|
||||||
|
: intl.formatMessage(messages.languageMovies, {
|
||||||
|
language: intl.formatDisplayName(router.query.language as string, {
|
||||||
|
type: 'language',
|
||||||
|
fallback: 'none',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle title={title} />
|
||||||
|
<div className="mt-1 mb-5">
|
||||||
|
<Header>{title}</Header>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
items={titles}
|
||||||
|
isEmpty={isEmpty}
|
||||||
|
isLoading={
|
||||||
|
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||||
|
}
|
||||||
|
isReachingEnd={isReachingEnd}
|
||||||
|
onScrollBottom={fetchMore}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscoverMovieLanguage;
|
||||||
@@ -1,80 +1,40 @@
|
|||||||
import React, { useContext } from 'react';
|
import React from 'react';
|
||||||
import { useSWRInfinite } from 'swr';
|
|
||||||
import type { MovieResult } from '../../../server/models/Search';
|
import type { MovieResult } from '../../../server/models/Search';
|
||||||
import ListView from '../Common/ListView';
|
import ListView from '../Common/ListView';
|
||||||
import { LanguageContext } from '../../context/LanguageContext';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
||||||
import Header from '../Common/Header';
|
import Header from '../Common/Header';
|
||||||
import useSettings from '../../hooks/useSettings';
|
|
||||||
import { MediaStatus } from '../../../server/constants/media';
|
|
||||||
import PageTitle from '../Common/PageTitle';
|
import PageTitle from '../Common/PageTitle';
|
||||||
|
import useDiscover from '../../hooks/useDiscover';
|
||||||
|
import Error from '../../pages/_error';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
discovermovies: 'Popular Movies',
|
discovermovies: 'Popular Movies',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface SearchResult {
|
|
||||||
page: number;
|
|
||||||
totalResults: number;
|
|
||||||
totalPages: number;
|
|
||||||
results: MovieResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const DiscoverMovies: React.FC = () => {
|
const DiscoverMovies: React.FC = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const settings = useSettings();
|
|
||||||
const { locale } = useContext(LanguageContext);
|
|
||||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
|
||||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
|
||||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `/api/v1/discover/movies?page=${pageIndex + 1}&language=${locale}`;
|
const {
|
||||||
},
|
isLoadingInitialData,
|
||||||
{
|
isEmpty,
|
||||||
initialSize: 3,
|
isLoadingMore,
|
||||||
}
|
isReachingEnd,
|
||||||
);
|
titles,
|
||||||
|
fetchMore,
|
||||||
const isLoadingInitialData = !data && !error;
|
error,
|
||||||
const isLoadingMore =
|
} = useDiscover<MovieResult>('/api/v1/discover/movies');
|
||||||
isLoadingInitialData ||
|
|
||||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
|
||||||
|
|
||||||
const fetchMore = () => {
|
|
||||||
setSize(size + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div>{error}</div>;
|
return <Error statusCode={500} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
let titles = (data ?? []).reduce(
|
const title = intl.formatMessage(messages.discovermovies);
|
||||||
(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);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle title={intl.formatMessage(messages.discovermovies)} />
|
<PageTitle title={title} />
|
||||||
<div className="mt-1 mb-5">
|
<div className="mt-1 mb-5">
|
||||||
<Header>
|
<Header>{title}</Header>
|
||||||
<FormattedMessage {...messages.discovermovies} />
|
|
||||||
</Header>
|
|
||||||
</div>
|
</div>
|
||||||
<ListView
|
<ListView
|
||||||
items={titles}
|
items={titles}
|
||||||
|
|||||||
75
src/components/Discover/DiscoverNetwork/index.tsx
Normal file
75
src/components/Discover/DiscoverNetwork/index.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
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';
|
||||||
|
import { TvNetwork } from '../../../../server/models/common';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
networkSeries: '{network} Series',
|
||||||
|
});
|
||||||
|
|
||||||
|
const DiscoverTvNetwork: React.FC = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoadingInitialData,
|
||||||
|
isEmpty,
|
||||||
|
isLoadingMore,
|
||||||
|
isReachingEnd,
|
||||||
|
titles,
|
||||||
|
fetchMore,
|
||||||
|
error,
|
||||||
|
firstResultData,
|
||||||
|
} = useDiscover<TvResult, { network: TvNetwork }>(
|
||||||
|
`/api/v1/discover/tv/network/${router.query.networkId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <Error statusCode={500} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = isLoadingInitialData
|
||||||
|
? intl.formatMessage(globalMessages.loading)
|
||||||
|
: intl.formatMessage(messages.networkSeries, {
|
||||||
|
network: firstResultData?.network.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle title={title} />
|
||||||
|
<div className="mt-1 mb-5">
|
||||||
|
<Header>
|
||||||
|
{firstResultData?.network.logoPath ? (
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<img
|
||||||
|
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`}
|
||||||
|
alt={firstResultData.network.name}
|
||||||
|
className="max-h-24 sm:max-h-32"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
title
|
||||||
|
)}
|
||||||
|
</Header>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
items={titles}
|
||||||
|
isEmpty={isEmpty}
|
||||||
|
isLoading={
|
||||||
|
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||||
|
}
|
||||||
|
isReachingEnd={isReachingEnd}
|
||||||
|
onScrollBottom={fetchMore}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscoverTvNetwork;
|
||||||
75
src/components/Discover/DiscoverStudio/index.tsx
Normal file
75
src/components/Discover/DiscoverStudio/index.tsx
Normal file
@@ -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<MovieResult, { studio: ProductionCompany }>(
|
||||||
|
`/api/v1/discover/movies/studio/${router.query.studioId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <Error statusCode={500} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = isLoadingInitialData
|
||||||
|
? intl.formatMessage(globalMessages.loading)
|
||||||
|
: intl.formatMessage(messages.studioMovies, {
|
||||||
|
studio: firstResultData?.studio.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle title={title} />
|
||||||
|
<div className="mt-1 mb-5">
|
||||||
|
<Header>
|
||||||
|
{firstResultData?.studio.logoPath ? (
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<img
|
||||||
|
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`}
|
||||||
|
alt={firstResultData.studio.name}
|
||||||
|
className="max-h-24 sm:max-h-32"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
title
|
||||||
|
)}
|
||||||
|
</Header>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
items={titles}
|
||||||
|
isEmpty={isEmpty}
|
||||||
|
isLoading={
|
||||||
|
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||||
|
}
|
||||||
|
isReachingEnd={isReachingEnd}
|
||||||
|
onScrollBottom={fetchMore}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscoverMovieStudio;
|
||||||
@@ -1,79 +1,40 @@
|
|||||||
import React, { useContext } from 'react';
|
import React from 'react';
|
||||||
import { useSWRInfinite } from 'swr';
|
|
||||||
import type { TvResult } from '../../../server/models/Search';
|
import type { TvResult } from '../../../server/models/Search';
|
||||||
import ListView from '../Common/ListView';
|
import ListView from '../Common/ListView';
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
import { LanguageContext } from '../../context/LanguageContext';
|
|
||||||
import Header from '../Common/Header';
|
import Header from '../Common/Header';
|
||||||
import useSettings from '../../hooks/useSettings';
|
|
||||||
import { MediaStatus } from '../../../server/constants/media';
|
|
||||||
import PageTitle from '../Common/PageTitle';
|
import PageTitle from '../Common/PageTitle';
|
||||||
|
import useDiscover from '../../hooks/useDiscover';
|
||||||
|
import Error from '../../pages/_error';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
discovertv: 'Popular Series',
|
discovertv: 'Popular Series',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface SearchResult {
|
|
||||||
page: number;
|
|
||||||
totalResults: number;
|
|
||||||
totalPages: number;
|
|
||||||
results: TvResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const DiscoverTv: React.FC = () => {
|
const DiscoverTv: React.FC = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const settings = useSettings();
|
|
||||||
const { locale } = useContext(LanguageContext);
|
|
||||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
|
||||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
|
||||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `/api/v1/discover/tv?page=${pageIndex + 1}&language=${locale}`;
|
const {
|
||||||
},
|
isLoadingInitialData,
|
||||||
{
|
isEmpty,
|
||||||
initialSize: 3,
|
isLoadingMore,
|
||||||
}
|
isReachingEnd,
|
||||||
);
|
titles,
|
||||||
|
fetchMore,
|
||||||
const isLoadingInitialData = !data && !error;
|
error,
|
||||||
const isLoadingMore =
|
} = useDiscover<TvResult>('/api/v1/discover/tv');
|
||||||
isLoadingInitialData ||
|
|
||||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
|
||||||
|
|
||||||
const fetchMore = () => {
|
|
||||||
setSize(size + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div>{error}</div>;
|
return <Error statusCode={500} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
let titles = (data ?? []).reduce(
|
const title = intl.formatMessage(messages.discovertv);
|
||||||
(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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle title={intl.formatMessage(messages.discovertv)} />
|
<PageTitle title={title} />
|
||||||
<div className="mt-1 mb-5">
|
<div className="mt-1 mb-5">
|
||||||
<Header>
|
<Header>{title}</Header>
|
||||||
<FormattedMessage {...messages.discovertv} />
|
|
||||||
</Header>
|
|
||||||
</div>
|
</div>
|
||||||
<ListView
|
<ListView
|
||||||
items={titles}
|
items={titles}
|
||||||
|
|||||||
62
src/components/Discover/DiscoverTvGenre/index.tsx
Normal file
62
src/components/Discover/DiscoverTvGenre/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
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({
|
||||||
|
genreSeries: '{genre} Series',
|
||||||
|
});
|
||||||
|
|
||||||
|
const DiscoverTvGenre: React.FC = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoadingInitialData,
|
||||||
|
isEmpty,
|
||||||
|
isLoadingMore,
|
||||||
|
isReachingEnd,
|
||||||
|
titles,
|
||||||
|
fetchMore,
|
||||||
|
error,
|
||||||
|
firstResultData,
|
||||||
|
} = useDiscover<TvResult, { genre: { id: number; name: string } }>(
|
||||||
|
`/api/v1/discover/tv/genre/${router.query.genreId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <Error statusCode={500} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = isLoadingInitialData
|
||||||
|
? intl.formatMessage(globalMessages.loading)
|
||||||
|
: intl.formatMessage(messages.genreSeries, {
|
||||||
|
genre: firstResultData?.genre.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle title={title} />
|
||||||
|
<div className="mt-1 mb-5">
|
||||||
|
<Header>{title}</Header>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
items={titles}
|
||||||
|
isEmpty={isEmpty}
|
||||||
|
isLoading={
|
||||||
|
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||||
|
}
|
||||||
|
isReachingEnd={isReachingEnd}
|
||||||
|
onScrollBottom={fetchMore}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscoverTvGenre;
|
||||||
71
src/components/Discover/DiscoverTvLanguage/index.tsx
Normal file
71
src/components/Discover/DiscoverTvLanguage/index.tsx
Normal file
@@ -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 <Error statusCode={500} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = isLoadingInitialData
|
||||||
|
? intl.formatMessage(globalMessages.loading)
|
||||||
|
: intl.formatMessage(messages.languageSeries, {
|
||||||
|
language: intl.formatDisplayName(router.query.language as string, {
|
||||||
|
type: 'language',
|
||||||
|
fallback: 'none',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle title={title} />
|
||||||
|
<div className="mt-1 mb-5">
|
||||||
|
<Header>{title}</Header>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
items={titles}
|
||||||
|
isEmpty={isEmpty}
|
||||||
|
isLoading={
|
||||||
|
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||||
|
}
|
||||||
|
isReachingEnd={isReachingEnd}
|
||||||
|
onScrollBottom={fetchMore}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscoverTvLanguage;
|
||||||
@@ -1,81 +1,38 @@
|
|||||||
import React, { useContext } from 'react';
|
import React from 'react';
|
||||||
import { useSWRInfinite } from 'swr';
|
|
||||||
import type { TvResult } from '../../../server/models/Search';
|
import type { TvResult } from '../../../server/models/Search';
|
||||||
import ListView from '../Common/ListView';
|
import ListView from '../Common/ListView';
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
import { LanguageContext } from '../../context/LanguageContext';
|
|
||||||
import Header from '../Common/Header';
|
import Header from '../Common/Header';
|
||||||
import useSettings from '../../hooks/useSettings';
|
|
||||||
import { MediaStatus } from '../../../server/constants/media';
|
|
||||||
import PageTitle from '../Common/PageTitle';
|
import PageTitle from '../Common/PageTitle';
|
||||||
|
import useDiscover from '../../hooks/useDiscover';
|
||||||
|
import Error from '../../pages/_error';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
upcomingtv: 'Upcoming Series',
|
upcomingtv: 'Upcoming Series',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface SearchResult {
|
|
||||||
page: number;
|
|
||||||
totalResults: number;
|
|
||||||
totalPages: number;
|
|
||||||
results: TvResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const DiscoverTvUpcoming: React.FC = () => {
|
const DiscoverTvUpcoming: React.FC = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const settings = useSettings();
|
|
||||||
const { locale } = useContext(LanguageContext);
|
|
||||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
|
||||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
|
||||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `/api/v1/discover/tv/upcoming?page=${
|
const {
|
||||||
pageIndex + 1
|
isLoadingInitialData,
|
||||||
}&language=${locale}`;
|
isEmpty,
|
||||||
},
|
isLoadingMore,
|
||||||
{
|
isReachingEnd,
|
||||||
initialSize: 3,
|
titles,
|
||||||
}
|
fetchMore,
|
||||||
);
|
error,
|
||||||
|
} = useDiscover<TvResult>('/api/v1/discover/tv/upcoming');
|
||||||
const isLoadingInitialData = !data && !error;
|
|
||||||
const isLoadingMore =
|
|
||||||
isLoadingInitialData ||
|
|
||||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
|
||||||
|
|
||||||
const fetchMore = () => {
|
|
||||||
setSize(size + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div>{error}</div>;
|
return <Error statusCode={500} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle title={intl.formatMessage(messages.upcomingtv)} />
|
<PageTitle title={intl.formatMessage(messages.upcomingtv)} />
|
||||||
<div className="mt-1 mb-5">
|
<div className="mt-1 mb-5">
|
||||||
<Header>
|
<Header>{intl.formatMessage(messages.upcomingtv)}</Header>
|
||||||
<FormattedMessage {...messages.upcomingtv} />
|
|
||||||
</Header>
|
|
||||||
</div>
|
</div>
|
||||||
<ListView
|
<ListView
|
||||||
items={titles}
|
items={titles}
|
||||||
|
|||||||
149
src/components/Discover/NetworkSlider/index.tsx
Normal file
149
src/components/Discover/NetworkSlider/index.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import CompanyCard from '../../CompanyCard';
|
||||||
|
import Slider from '../../Slider';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
networks: 'Networks',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Network {
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const networks: Network[] = [
|
||||||
|
{
|
||||||
|
name: 'Netflix',
|
||||||
|
image:
|
||||||
|
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/wwemzKWzjKYJFfCeiB57q3r4Bcm.png',
|
||||||
|
url: '/discover/tv/network/213',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Disney+',
|
||||||
|
image:
|
||||||
|
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/gJ8VX6JSu3ciXHuC2dDGAo2lvwM.png',
|
||||||
|
url: '/discover/tv/network/2739',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Prime Video',
|
||||||
|
image:
|
||||||
|
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ifhbNuuVnlwYy5oXA5VIb2YR8AZ.png',
|
||||||
|
url: '/discover/tv/network/1024',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'HBO',
|
||||||
|
image:
|
||||||
|
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/tuomPhY2UtuPTqqFnKMVHvSb724.png',
|
||||||
|
url: '/discover/tv/network/49',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ABC',
|
||||||
|
image:
|
||||||
|
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ndAvF4JLsliGreX87jAc9GdjmJY.png',
|
||||||
|
url: '/discover/tv/network/2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'FOX',
|
||||||
|
image:
|
||||||
|
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/1DSpHrWyOORkL9N2QHX7Adt31mQ.png',
|
||||||
|
url: '/discover/tv/network/19',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cinemax',
|
||||||
|
image:
|
||||||
|
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/6mSHSquNpfLgDdv6VnOOvC5Uz2h.png',
|
||||||
|
url: '/discover/tv/network/359',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'AMC',
|
||||||
|
image:
|
||||||
|
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/pmvRmATOCaDykE6JrVoeYxlFHw3.png',
|
||||||
|
url: '/discover/tv/network/174',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Showtime',
|
||||||
|
image:
|
||||||
|
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/Allse9kbjiP6ExaQrnSpIhkurEi.png',
|
||||||
|
url: '/discover/tv/network/67',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Starz',
|
||||||
|
image:
|
||||||
|
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/8GJjw3HHsAJYwIWKIPBPfqMxlEa.png',
|
||||||
|
url: '/discover/tv/network/318',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'The CW',
|
||||||
|
image:
|
||||||
|
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ge9hzeaU7nMtQ4PjkFlc68dGAJ9.png',
|
||||||
|
url: '/discover/tv/network/71',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'NBC',
|
||||||
|
image:
|
||||||
|
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/o3OedEP0f9mfZr33jz2BfXOUK5.png',
|
||||||
|
url: '/discover/tv/network/6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CBS',
|
||||||
|
image:
|
||||||
|
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/nm8d7P7MJNiBLdgIzUK0gkuEA4r.png',
|
||||||
|
url: '/discover/tv/network/16',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'BBC One',
|
||||||
|
image:
|
||||||
|
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/mVn7xESaTNmjBUyUtGNvDQd3CT1.png',
|
||||||
|
url: '/discover/tv/network/4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cartoon Network',
|
||||||
|
image:
|
||||||
|
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/c5OC6oVCg6QP4eqzW6XIq17CQjI.png',
|
||||||
|
url: '/discover/tv/network/56',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Adult Swim',
|
||||||
|
image:
|
||||||
|
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/9AKyspxVzywuaMuZ1Bvilu8sXly.png',
|
||||||
|
url: '/discover/tv/network/80',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Nickelodeon',
|
||||||
|
image:
|
||||||
|
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ikZXxg6GnwpzqiZbRPhJGaZapqB.png',
|
||||||
|
url: '/discover/tv/network/13',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const NetworkSlider: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="slider-header">
|
||||||
|
<div className="slider-title">
|
||||||
|
<span>{intl.formatMessage(messages.networks)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
sliderKey="networks"
|
||||||
|
isLoading={false}
|
||||||
|
isEmpty={false}
|
||||||
|
items={networks.map((network, index) => (
|
||||||
|
<CompanyCard
|
||||||
|
key={`network-${index}`}
|
||||||
|
name={network.name}
|
||||||
|
image={network.image}
|
||||||
|
url={network.url}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
emptyMessage=""
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NetworkSlider;
|
||||||
107
src/components/Discover/StudioSlider/index.tsx
Normal file
107
src/components/Discover/StudioSlider/index.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="slider-header">
|
||||||
|
<div className="slider-title">
|
||||||
|
<span>{intl.formatMessage(messages.studios)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
sliderKey="studios"
|
||||||
|
isLoading={false}
|
||||||
|
isEmpty={false}
|
||||||
|
items={studios.map((studio, index) => (
|
||||||
|
<CompanyCard
|
||||||
|
key={`studio-${index}`}
|
||||||
|
name={studio.name}
|
||||||
|
image={studio.image}
|
||||||
|
url={studio.url}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
emptyMessage=""
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StudioSlider;
|
||||||
@@ -1,86 +1,43 @@
|
|||||||
import React, { useContext } from 'react';
|
import React from 'react';
|
||||||
import { useSWRInfinite } from 'swr';
|
|
||||||
import type {
|
import type {
|
||||||
MovieResult,
|
MovieResult,
|
||||||
TvResult,
|
TvResult,
|
||||||
PersonResult,
|
PersonResult,
|
||||||
} from '../../../server/models/Search';
|
} from '../../../server/models/Search';
|
||||||
import ListView from '../Common/ListView';
|
import ListView from '../Common/ListView';
|
||||||
import { LanguageContext } from '../../context/LanguageContext';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
||||||
import Header from '../Common/Header';
|
import Header from '../Common/Header';
|
||||||
import useSettings from '../../hooks/useSettings';
|
|
||||||
import { MediaStatus } from '../../../server/constants/media';
|
|
||||||
import PageTitle from '../Common/PageTitle';
|
import PageTitle from '../Common/PageTitle';
|
||||||
|
import useDiscover from '../../hooks/useDiscover';
|
||||||
|
import Error from '../../pages/_error';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
trending: 'Trending',
|
trending: 'Trending',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface SearchResult {
|
|
||||||
page: number;
|
|
||||||
totalResults: number;
|
|
||||||
totalPages: number;
|
|
||||||
results: (MovieResult | TvResult | PersonResult)[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const Trending: React.FC = () => {
|
const Trending: React.FC = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const settings = useSettings();
|
const {
|
||||||
const { locale } = useContext(LanguageContext);
|
isLoadingInitialData,
|
||||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
isEmpty,
|
||||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
isLoadingMore,
|
||||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
isReachingEnd,
|
||||||
return null;
|
titles,
|
||||||
}
|
fetchMore,
|
||||||
|
error,
|
||||||
return `/api/v1/discover/trending?page=${
|
} = useDiscover<MovieResult | TvResult | PersonResult>(
|
||||||
pageIndex + 1
|
'/api/v1/discover/trending'
|
||||||
}&language=${locale}`;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
initialSize: 3,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const isLoadingInitialData = !data && !error;
|
|
||||||
const isLoadingMore =
|
|
||||||
isLoadingInitialData ||
|
|
||||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
|
||||||
|
|
||||||
const fetchMore = () => {
|
|
||||||
setSize(size + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div>{error}</div>;
|
return <Error statusCode={500} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle title={intl.formatMessage(messages.trending)} />
|
<PageTitle title={intl.formatMessage(messages.trending)} />
|
||||||
<div className="mt-1 mb-5">
|
<div className="mt-1 mb-5">
|
||||||
<Header>
|
<Header>{intl.formatMessage(messages.trending)}</Header>
|
||||||
<FormattedMessage {...messages.trending} />
|
|
||||||
</Header>
|
|
||||||
</div>
|
</div>
|
||||||
<ListView
|
<ListView
|
||||||
items={titles}
|
items={titles}
|
||||||
|
|||||||
@@ -1,81 +1,38 @@
|
|||||||
import React, { useContext } from 'react';
|
import React from 'react';
|
||||||
import { useSWRInfinite } from 'swr';
|
|
||||||
import type { MovieResult } from '../../../server/models/Search';
|
import type { MovieResult } from '../../../server/models/Search';
|
||||||
import ListView from '../Common/ListView';
|
import ListView from '../Common/ListView';
|
||||||
import { LanguageContext } from '../../context/LanguageContext';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
||||||
import Header from '../Common/Header';
|
import Header from '../Common/Header';
|
||||||
import useSettings from '../../hooks/useSettings';
|
|
||||||
import { MediaStatus } from '../../../server/constants/media';
|
|
||||||
import PageTitle from '../Common/PageTitle';
|
import PageTitle from '../Common/PageTitle';
|
||||||
|
import useDiscover from '../../hooks/useDiscover';
|
||||||
|
import Error from '../../pages/_error';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
upcomingmovies: 'Upcoming Movies',
|
upcomingmovies: 'Upcoming Movies',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface SearchResult {
|
|
||||||
page: number;
|
|
||||||
totalResults: number;
|
|
||||||
totalPages: number;
|
|
||||||
results: MovieResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const UpcomingMovies: React.FC = () => {
|
const UpcomingMovies: React.FC = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const settings = useSettings();
|
|
||||||
const { locale } = useContext(LanguageContext);
|
|
||||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
|
||||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
|
||||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `/api/v1/discover/movies/upcoming?page=${
|
const {
|
||||||
pageIndex + 1
|
isLoadingInitialData,
|
||||||
}&language=${locale}`;
|
isEmpty,
|
||||||
},
|
isLoadingMore,
|
||||||
{
|
isReachingEnd,
|
||||||
initialSize: 3,
|
titles,
|
||||||
}
|
fetchMore,
|
||||||
);
|
error,
|
||||||
|
} = useDiscover<MovieResult>('/api/v1/discover/movies/upcoming');
|
||||||
const isLoadingInitialData = !data && !error;
|
|
||||||
const isLoadingMore =
|
|
||||||
isLoadingInitialData ||
|
|
||||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
|
||||||
|
|
||||||
const fetchMore = () => {
|
|
||||||
setSize(size + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div>{error}</div>;
|
return <Error statusCode={500} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle title={intl.formatMessage(messages.upcomingmovies)} />
|
<PageTitle title={intl.formatMessage(messages.upcomingmovies)} />
|
||||||
<div className="mt-1 mb-5">
|
<div className="mt-1 mb-5">
|
||||||
<Header>
|
<Header>{intl.formatMessage(messages.upcomingmovies)}</Header>
|
||||||
<FormattedMessage {...messages.upcomingmovies} />
|
|
||||||
</Header>
|
|
||||||
</div>
|
</div>
|
||||||
<ListView
|
<ListView
|
||||||
items={titles}
|
items={titles}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import useSWR from 'swr';
|
|||||||
import TmdbTitleCard from '../TitleCard/TmdbTitleCard';
|
import TmdbTitleCard from '../TitleCard/TmdbTitleCard';
|
||||||
import Slider from '../Slider';
|
import Slider from '../Slider';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
import type { MediaResultsResponse } from '../../../server/interfaces/api/mediaInterfaces';
|
import type { MediaResultsResponse } from '../../../server/interfaces/api/mediaInterfaces';
|
||||||
import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces';
|
import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces';
|
||||||
import RequestCard from '../RequestCard';
|
import RequestCard from '../RequestCard';
|
||||||
import MediaSlider from '../MediaSlider';
|
import MediaSlider from '../MediaSlider';
|
||||||
import PageTitle from '../Common/PageTitle';
|
import PageTitle from '../Common/PageTitle';
|
||||||
|
import StudioSlider from './StudioSlider';
|
||||||
|
import NetworkSlider from './NetworkSlider';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
discover: 'Discover',
|
discover: 'Discover',
|
||||||
@@ -39,13 +41,9 @@ const Discover: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle title={intl.formatMessage(messages.discover)} />
|
<PageTitle title={intl.formatMessage(messages.discover)} />
|
||||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
<div className="slider-header">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="slider-title">
|
||||||
<div className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
<span>{intl.formatMessage(messages.recentlyAdded)}</span>
|
||||||
<span>
|
|
||||||
<FormattedMessage {...messages.recentlyAdded} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
@@ -60,13 +58,10 @@ const Discover: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
/>
|
/>
|
||||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
<div className="slider-header">
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<Link href="/requests">
|
<Link href="/requests">
|
||||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
<a className="slider-title">
|
||||||
<span>
|
<span>{intl.formatMessage(messages.recentrequests)}</span>
|
||||||
<FormattedMessage {...messages.recentrequests} />
|
|
||||||
</span>
|
|
||||||
<svg
|
<svg
|
||||||
className="w-6 h-6 ml-2"
|
className="w-6 h-6 ml-2"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -84,7 +79,6 @@ const Discover: React.FC = () => {
|
|||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<Slider
|
<Slider
|
||||||
sliderKey="requests"
|
sliderKey="requests"
|
||||||
isLoading={!requests && !requestError}
|
isLoading={!requests && !requestError}
|
||||||
@@ -116,6 +110,7 @@ const Discover: React.FC = () => {
|
|||||||
linkUrl="/discover/movies/upcoming"
|
linkUrl="/discover/movies/upcoming"
|
||||||
url="/api/v1/discover/movies/upcoming"
|
url="/api/v1/discover/movies/upcoming"
|
||||||
/>
|
/>
|
||||||
|
<StudioSlider />
|
||||||
<MediaSlider
|
<MediaSlider
|
||||||
sliderKey="popular-tv"
|
sliderKey="popular-tv"
|
||||||
title={intl.formatMessage(messages.populartv)}
|
title={intl.formatMessage(messages.populartv)}
|
||||||
@@ -128,6 +123,7 @@ const Discover: React.FC = () => {
|
|||||||
url="/api/v1/discover/tv/upcoming"
|
url="/api/v1/discover/tv/upcoming"
|
||||||
linkUrl="/discover/tv/upcoming"
|
linkUrl="/discover/tv/upcoming"
|
||||||
/>
|
/>
|
||||||
|
<NetworkSlider />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
|
|||||||
plexUrl,
|
plexUrl,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-center w-full space-x-5">
|
||||||
{plexUrl && (
|
{plexUrl && (
|
||||||
<a
|
<a
|
||||||
href={plexUrl}
|
href={plexUrl}
|
||||||
className="w-8 mx-2 transition duration-300 opacity-50 hover:opacity-100"
|
className="w-12 transition duration-300 opacity-50 hover:opacity-100"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
@@ -38,7 +38,7 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
|
|||||||
{tmdbId && (
|
{tmdbId && (
|
||||||
<a
|
<a
|
||||||
href={`https://www.themoviedb.org/${mediaType}/${tmdbId}`}
|
href={`https://www.themoviedb.org/${mediaType}/${tmdbId}`}
|
||||||
className="w-8 mx-2 transition duration-300 opacity-50 hover:opacity-100"
|
className="w-8 transition duration-300 opacity-50 hover:opacity-100"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
@@ -48,7 +48,7 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
|
|||||||
{tvdbId && mediaType === MediaType.TV && (
|
{tvdbId && mediaType === MediaType.TV && (
|
||||||
<a
|
<a
|
||||||
href={`http://www.thetvdb.com/?tab=series&id=${tvdbId}`}
|
href={`http://www.thetvdb.com/?tab=series&id=${tvdbId}`}
|
||||||
className="w-8 mx-2 transition duration-300 opacity-50 hover:opacity-100"
|
className="transition duration-300 opacity-50 w-9 hover:opacity-100"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
@@ -58,7 +58,7 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
|
|||||||
{imdbId && (
|
{imdbId && (
|
||||||
<a
|
<a
|
||||||
href={`https://www.imdb.com/title/${imdbId}`}
|
href={`https://www.imdb.com/title/${imdbId}`}
|
||||||
className="w-8 mx-2 transition duration-300 opacity-50 hover:opacity-100"
|
className="w-8 transition duration-300 opacity-50 hover:opacity-100"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
@@ -68,7 +68,7 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
|
|||||||
{rtUrl && (
|
{rtUrl && (
|
||||||
<a
|
<a
|
||||||
href={`${rtUrl}`}
|
href={`${rtUrl}`}
|
||||||
className="mx-2 transition duration-300 opacity-50 w-14 hover:opacity-100"
|
className="transition duration-300 opacity-50 w-14 hover:opacity-100"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -89,7 +89,9 @@ const LanguagePicker: React.FC = () => {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
className="p-1 text-gray-400 rounded-full hover:bg-gray-600 hover:text-white focus:outline-none focus:ring focus:text-white"
|
className={`p-1 rounded-full sm:p-2 hover:bg-gray-600 hover:text-white focus:outline-none focus:bg-gray-600 focus:ring-1 focus:ring-gray-500 focus:text-white ${
|
||||||
|
isDropdownOpen ? 'bg-gray-600 text-white' : 'text-gray-400'
|
||||||
|
}`}
|
||||||
aria-label="Language Picker"
|
aria-label="Language Picker"
|
||||||
onClick={() => setDropdownOpen(true)}
|
onClick={() => setDropdownOpen(true)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const SearchInput: React.FC = () => {
|
|||||||
<input
|
<input
|
||||||
id="search_field"
|
id="search_field"
|
||||||
style={{ paddingRight: searchValue.length > 0 ? '1.75rem' : '' }}
|
style={{ paddingRight: searchValue.length > 0 ? '1.75rem' : '' }}
|
||||||
className="block w-full py-2 pl-10 text-white placeholder-gray-300 bg-gray-900 border border-gray-600 rounded-full focus:border-gray-500 focus:outline-none focus:ring-0 focus:placeholder-gray-400 sm:text-base"
|
className="block w-full py-2 pl-10 text-white placeholder-gray-300 bg-gray-900 border border-gray-600 rounded-full focus:border-gray-500 hover:border-gray-500 focus:outline-none focus:ring-0 focus:placeholder-gray-400 sm:text-base"
|
||||||
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
||||||
type="search"
|
type="search"
|
||||||
value={searchValue}
|
value={searchValue}
|
||||||
|
|||||||
@@ -31,13 +31,17 @@ const UserDropdown: React.FC = () => {
|
|||||||
<div className="relative ml-3">
|
<div className="relative ml-3">
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
className="flex items-center max-w-xs text-sm rounded-full focus:outline-none focus:ring"
|
className="flex items-center max-w-xs text-sm rounded-full ring-1 ring-gray-700 focus:outline-none focus:ring-gray-500 hover:ring-gray-500"
|
||||||
id="user-menu"
|
id="user-menu"
|
||||||
aria-label="User menu"
|
aria-label="User menu"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
onClick={() => setDropdownOpen(true)}
|
onClick={() => setDropdownOpen(true)}
|
||||||
>
|
>
|
||||||
<img className="w-8 h-8 rounded-full" src={user?.avatar} alt="" />
|
<img
|
||||||
|
className="w-8 h-8 rounded-full sm:w-10 sm:h-10"
|
||||||
|
src={user?.avatar}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Transition
|
<Transition
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ const Layout: React.FC = ({ children }) => {
|
|||||||
</button>
|
</button>
|
||||||
<div className="flex justify-between flex-1 pr-4 md:pr-4 md:pl-4">
|
<div className="flex justify-between flex-1 pr-4 md:pr-4 md:pl-4">
|
||||||
<SearchInput />
|
<SearchInput />
|
||||||
<div className="flex items-center ml-2 md:ml-4">
|
<div className="flex items-center ml-2">
|
||||||
<LanguagePicker />
|
<LanguagePicker />
|
||||||
<UserDropdown />
|
<UserDropdown />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
73
src/components/LoadingBar/index.tsx
Normal file
73
src/components/LoadingBar/index.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={`fixed top-0 left-0 z-50 w-full transition-opacity ease-out duration-400 ${
|
||||||
|
isFinished ? 'opacity-0' : 'opacity-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="duration-300 bg-indigo-400 transition-width"
|
||||||
|
style={{
|
||||||
|
height: '3px',
|
||||||
|
width: `${progress * 100}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const NProgressBar = ({ loading }: { loading: boolean }) => (
|
||||||
|
<NProgress isAnimating={loading}>
|
||||||
|
{({ isFinished, progress }) => (
|
||||||
|
<Bar progress={progress} isFinished={isFinished} />
|
||||||
|
)}
|
||||||
|
</NProgress>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<MemoizedNProgress loading={loading} />,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingBar;
|
||||||
@@ -63,7 +63,7 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
|
|||||||
{intl.formatMessage(messages.email)}
|
{intl.formatMessage(messages.email)}
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
<div className="form-input-field">
|
||||||
<Field
|
<Field
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
@@ -79,7 +79,7 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
|
|||||||
{intl.formatMessage(messages.password)}
|
{intl.formatMessage(messages.password)}
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
<div className="form-input-field">
|
||||||
<Field
|
<Field
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ const ShowMoreCard: React.FC<ShowMoreCardProps> = ({ url, posters }) => {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`relative w-36 sm:w-36 md:w-44
|
className={`relative w-36 sm:w-36 md:w-44
|
||||||
rounded-lg text-white shadow-lg overflow-hidden transition ease-in-out duration-150 cursor-pointer transform-gpu ${
|
rounded-xl text-white shadow-lg overflow-hidden transition ease-in-out duration-150 cursor-pointer transform-gpu ring-1 ${
|
||||||
isHovered ? 'bg-gray-500 scale-105' : 'bg-gray-600 scale-100'
|
isHovered
|
||||||
|
? 'bg-gray-600 ring-gray-500 scale-105'
|
||||||
|
: 'bg-gray-800 ring-gray-700 scale-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div style={{ paddingBottom: '150%' }}>
|
<div style={{ paddingBottom: '150%' }}>
|
||||||
|
|||||||
@@ -135,11 +135,10 @@ const MediaSlider: React.FC<MediaSliderProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
<div className="slider-header">
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{linkUrl ? (
|
{linkUrl ? (
|
||||||
<Link href={linkUrl}>
|
<Link href={linkUrl}>
|
||||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
<a className="slider-title">
|
||||||
<span>{title}</span>
|
<span>{title}</span>
|
||||||
<svg
|
<svg
|
||||||
className="w-6 h-6 ml-2"
|
className="w-6 h-6 ml-2"
|
||||||
@@ -158,12 +157,11 @@ const MediaSlider: React.FC<MediaSliderProps> = ({
|
|||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<div className="inline-flex items-center text-xl leading-7 text-gray-300 sm:text-2xl sm:leading-9 sm:truncate">
|
<div className="slider-title">
|
||||||
<span>{title}</span>
|
<span>{title}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<Slider
|
<Slider
|
||||||
sliderKey={sliderKey}
|
sliderKey={sliderKey}
|
||||||
isLoading={!data && !error}
|
isLoading={!data && !error}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useContext } from 'react';
|
import React, { useContext } from 'react';
|
||||||
import useSWR, { useSWRInfinite } from 'swr';
|
import useSWR from 'swr';
|
||||||
import type { MovieResult } from '../../../server/models/Search';
|
import type { MovieResult } from '../../../server/models/Search';
|
||||||
import ListView from '../Common/ListView';
|
import ListView from '../Common/ListView';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
@@ -7,75 +7,38 @@ import Header from '../Common/Header';
|
|||||||
import type { MovieDetails } from '../../../server/models/Movie';
|
import type { MovieDetails } from '../../../server/models/Movie';
|
||||||
import { LanguageContext } from '../../context/LanguageContext';
|
import { LanguageContext } from '../../context/LanguageContext';
|
||||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||||
import useSettings from '../../hooks/useSettings';
|
|
||||||
import { MediaStatus } from '../../../server/constants/media';
|
|
||||||
import PageTitle from '../Common/PageTitle';
|
import PageTitle from '../Common/PageTitle';
|
||||||
|
import useDiscover from '../../hooks/useDiscover';
|
||||||
|
import Error from '../../pages/_error';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
recommendations: 'Recommendations',
|
recommendations: 'Recommendations',
|
||||||
recommendationssubtext: 'If you liked {title}, you might also like…',
|
recommendationssubtext: 'If you liked {title}, you might also like…',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface SearchResult {
|
|
||||||
page: number;
|
|
||||||
totalResults: number;
|
|
||||||
totalPages: number;
|
|
||||||
results: MovieResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const MovieRecommendations: React.FC = () => {
|
const MovieRecommendations: React.FC = () => {
|
||||||
const settings = useSettings();
|
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { locale } = useContext(LanguageContext);
|
const { locale } = useContext(LanguageContext);
|
||||||
const { data: movieData, error: movieError } = useSWR<MovieDetails>(
|
const { data: movieData, error: movieError } = useSWR<MovieDetails>(
|
||||||
`/api/v1/movie/${router.query.movieId}?language=${locale}`
|
`/api/v1/movie/${router.query.movieId}?language=${locale}`
|
||||||
);
|
);
|
||||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
const {
|
||||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
isLoadingInitialData,
|
||||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
isEmpty,
|
||||||
return null;
|
isLoadingMore,
|
||||||
}
|
isReachingEnd,
|
||||||
|
titles,
|
||||||
return `/api/v1/movie/${router.query.movieId}/recommendations?page=${
|
fetchMore,
|
||||||
pageIndex + 1
|
error,
|
||||||
}&language=${locale}`;
|
} = useDiscover<MovieResult>(
|
||||||
},
|
`/api/v1/movie/${router.query.movieId}/recommendations`
|
||||||
{
|
|
||||||
initialSize: 3,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const isLoadingInitialData = !data && !error;
|
|
||||||
const isLoadingMore =
|
|
||||||
isLoadingInitialData ||
|
|
||||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
|
||||||
|
|
||||||
const fetchMore = () => {
|
|
||||||
setSize(size + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div>{error}</div>;
|
return <Error statusCode={500} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useContext } from 'react';
|
import React, { useContext } from 'react';
|
||||||
import useSWR, { useSWRInfinite } from 'swr';
|
import useSWR from 'swr';
|
||||||
import type { MovieResult } from '../../../server/models/Search';
|
import type { MovieResult } from '../../../server/models/Search';
|
||||||
import ListView from '../Common/ListView';
|
import ListView from '../Common/ListView';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
@@ -7,75 +7,36 @@ import Header from '../Common/Header';
|
|||||||
import { LanguageContext } from '../../context/LanguageContext';
|
import { LanguageContext } from '../../context/LanguageContext';
|
||||||
import type { MovieDetails } from '../../../server/models/Movie';
|
import type { MovieDetails } from '../../../server/models/Movie';
|
||||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||||
import { MediaStatus } from '../../../server/constants/media';
|
|
||||||
import useSettings from '../../hooks/useSettings';
|
|
||||||
import PageTitle from '../Common/PageTitle';
|
import PageTitle from '../Common/PageTitle';
|
||||||
|
import useDiscover from '../../hooks/useDiscover';
|
||||||
|
import Error from '../../pages/_error';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
similar: 'Similar Titles',
|
similar: 'Similar Titles',
|
||||||
similarsubtext: 'Other movies similar to {title}',
|
similarsubtext: 'Other movies similar to {title}',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface SearchResult {
|
|
||||||
page: number;
|
|
||||||
totalResults: number;
|
|
||||||
totalPages: number;
|
|
||||||
results: MovieResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const MovieSimilar: React.FC = () => {
|
const MovieSimilar: React.FC = () => {
|
||||||
const settings = useSettings();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { locale } = useContext(LanguageContext);
|
const { locale } = useContext(LanguageContext);
|
||||||
const { data: movieData, error: movieError } = useSWR<MovieDetails>(
|
const { data: movieData, error: movieError } = useSWR<MovieDetails>(
|
||||||
`/api/v1/movie/${router.query.movieId}?language=${locale}`
|
`/api/v1/movie/${router.query.movieId}?language=${locale}`
|
||||||
);
|
);
|
||||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
const {
|
||||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
isLoadingInitialData,
|
||||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
isEmpty,
|
||||||
return null;
|
isLoadingMore,
|
||||||
}
|
isReachingEnd,
|
||||||
|
titles,
|
||||||
return `/api/v1/movie/${router.query.movieId}/similar?page=${
|
fetchMore,
|
||||||
pageIndex + 1
|
error,
|
||||||
}&language=${locale}`;
|
} = useDiscover<MovieResult>(`/api/v1/movie/${router.query.movieId}/similar`);
|
||||||
},
|
|
||||||
{
|
|
||||||
initialSize: 3,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const isLoadingInitialData = !data && !error;
|
|
||||||
const isLoadingMore =
|
|
||||||
isLoadingInitialData ||
|
|
||||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
|
||||||
|
|
||||||
const fetchMore = () => {
|
|
||||||
setSize(size + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div>{error}</div>;
|
return <Error statusCode={500} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import React, { useState, useContext, useMemo } from 'react';
|
import React, { useState, useContext, useMemo } from 'react';
|
||||||
import {
|
import { defineMessages, FormattedNumber, useIntl } from 'react-intl';
|
||||||
defineMessages,
|
|
||||||
FormattedNumber,
|
|
||||||
FormattedDate,
|
|
||||||
useIntl,
|
|
||||||
} from 'react-intl';
|
|
||||||
import type { MovieDetails as MovieDetailsType } from '../../../server/models/Movie';
|
import type { MovieDetails as MovieDetailsType } from '../../../server/models/Movie';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
@@ -60,10 +55,11 @@ const messages = defineMessages({
|
|||||||
manageModalNoRequests: 'No Requests',
|
manageModalNoRequests: 'No Requests',
|
||||||
manageModalClearMedia: 'Clear All Media Data',
|
manageModalClearMedia: 'Clear All Media Data',
|
||||||
manageModalClearMediaWarning:
|
manageModalClearMediaWarning:
|
||||||
'This will irreversibly remove all data for this movie, including any requests. If this item exists in your Plex library, the media information will be recreated during the next sync.',
|
'This will irreversibly remove all data for this movie, including any requests.\
|
||||||
|
If this item exists in your Plex library, the media information will be recreated during the next scan.',
|
||||||
approve: 'Approve',
|
approve: 'Approve',
|
||||||
decline: 'Decline',
|
decline: 'Decline',
|
||||||
studio: 'Studio',
|
studio: '{studioCount, plural, one {Studio} other {Studios}}',
|
||||||
viewfullcrew: 'View Full Crew',
|
viewfullcrew: 'View Full Crew',
|
||||||
view: 'View',
|
view: 'View',
|
||||||
areyousure: 'Are you sure?',
|
areyousure: 'Are you sure?',
|
||||||
@@ -187,12 +183,24 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.genres.length) {
|
if (data.genres.length) {
|
||||||
movieAttributes.push(data.genres.map((g) => g.name).join(', '));
|
movieAttributes.push(
|
||||||
|
data.genres
|
||||||
|
.map((g) => (
|
||||||
|
<Link href={`/discover/movies/genre/${g.id}`} key={`genre-${g.id}`}>
|
||||||
|
<a className="hover:underline">{g.name}</a>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
.reduce((prev, curr) => (
|
||||||
|
<>
|
||||||
|
{prev}, {curr}
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="px-4 pt-16 -mx-4 -mt-16 bg-center bg-cover"
|
className="media-page"
|
||||||
style={{
|
style={{
|
||||||
height: 493,
|
height: 493,
|
||||||
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
|
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
|
||||||
@@ -371,8 +379,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SlideOver>
|
</SlideOver>
|
||||||
<div className="flex flex-col items-center pt-4 lg:flex-row lg:items-end">
|
<div className="media-header">
|
||||||
<div className="lg:mr-4">
|
|
||||||
<img
|
<img
|
||||||
src={
|
src={
|
||||||
data.posterPath
|
data.posterPath
|
||||||
@@ -380,18 +387,15 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
|||||||
: '/images/overseerr_poster_not_found.png'
|
: '/images/overseerr_poster_not_found.png'
|
||||||
}
|
}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-44 lg:w-52"
|
className="media-poster"
|
||||||
/>
|
/>
|
||||||
</div>
|
<div className="media-title">
|
||||||
<div className="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left">
|
<div className="media-status">
|
||||||
<div className="mb-2 space-x-2">
|
|
||||||
<span className="ml-2 lg:ml-0">
|
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
status={data.mediaInfo?.status}
|
status={data.mediaInfo?.status}
|
||||||
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
|
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
|
||||||
plexUrl={data.mediaInfo?.plexUrl}
|
plexUrl={data.mediaInfo?.plexUrl}
|
||||||
/>
|
/>
|
||||||
</span>
|
|
||||||
{settings.currentSettings.movie4kEnabled &&
|
{settings.currentSettings.movie4kEnabled &&
|
||||||
hasPermission(
|
hasPermission(
|
||||||
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
|
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
|
||||||
@@ -399,7 +403,6 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
|||||||
type: 'or',
|
type: 'or',
|
||||||
}
|
}
|
||||||
) && (
|
) && (
|
||||||
<span>
|
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
status={data.mediaInfo?.status4k}
|
status={data.mediaInfo?.status4k}
|
||||||
is4k
|
is4k
|
||||||
@@ -408,16 +411,17 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
|||||||
}
|
}
|
||||||
plexUrl4k={data.mediaInfo?.plexUrl4k}
|
plexUrl4k={data.mediaInfo?.plexUrl4k}
|
||||||
/>
|
/>
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl lg:text-4xl">
|
<h1>
|
||||||
{data.title}{' '}
|
{data.title}{' '}
|
||||||
{data.releaseDate && (
|
{data.releaseDate && (
|
||||||
<span className="text-2xl">({data.releaseDate.slice(0, 4)})</span>
|
<span className="media-year">
|
||||||
|
({data.releaseDate.slice(0, 4)})
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</h1>
|
</h1>
|
||||||
<span className="mt-1 text-xs lg:text-base lg:mt-0">
|
<span className="media-attributes">
|
||||||
{movieAttributes.length > 0 &&
|
{movieAttributes.length > 0 &&
|
||||||
movieAttributes
|
movieAttributes
|
||||||
.map((t, k) => <span key={k}>{t}</span>)
|
.map((t, k) => <span key={k}>{t}</span>)
|
||||||
@@ -428,27 +432,23 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
|||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative z-10 flex flex-wrap justify-center flex-shrink-0 mt-4 sm:justify-end sm:flex-nowrap lg:mt-0">
|
<div className="media-actions">
|
||||||
<div className="mb-3 sm:mb-0">
|
|
||||||
<PlayButton links={mediaLinks} />
|
<PlayButton links={mediaLinks} />
|
||||||
</div>
|
|
||||||
<div className="mb-3 sm:mb-0">
|
|
||||||
<RequestButton
|
<RequestButton
|
||||||
mediaType="movie"
|
mediaType="movie"
|
||||||
media={data.mediaInfo}
|
media={data.mediaInfo}
|
||||||
tmdbId={data.id}
|
tmdbId={data.id}
|
||||||
onUpdate={() => revalidate()}
|
onUpdate={() => revalidate()}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{hasPermission(Permission.MANAGE_REQUESTS) && (
|
{hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||||
<Button
|
<Button
|
||||||
buttonType="default"
|
buttonType="default"
|
||||||
className="mb-3 ml-2 first:ml-0 sm:mb-0"
|
className="ml-2 first:ml-0"
|
||||||
onClick={() => setShowManager(true)}
|
onClick={() => setShowManager(true)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-5"
|
className="w-5"
|
||||||
style={{ height: 20 }}
|
style={{ height: 18 }}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -471,27 +471,21 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col pt-8 pb-4 text-white md:flex-row">
|
<div className="media-overview">
|
||||||
<div className="flex-1 md:mr-8">
|
<div className="media-overview-left">
|
||||||
<h2 className="text-xl md:text-2xl">
|
<div className="tagline">{data.tagline}</div>
|
||||||
{intl.formatMessage(messages.overview)}
|
<h2>{intl.formatMessage(messages.overview)}</h2>
|
||||||
</h2>
|
<p>
|
||||||
<p className="pt-2 text-sm md:text-base">
|
|
||||||
{data.overview
|
{data.overview
|
||||||
? data.overview
|
? data.overview
|
||||||
: intl.formatMessage(messages.overviewunavailable)}
|
: intl.formatMessage(messages.overviewunavailable)}
|
||||||
</p>
|
</p>
|
||||||
<ul className="grid grid-cols-2 gap-6 mt-6 sm:grid-cols-3">
|
<ul className="media-crew">
|
||||||
{sortedCrew.slice(0, 6).map((person) => (
|
{sortedCrew.slice(0, 6).map((person) => (
|
||||||
<li
|
<li key={`crew-${person.job}-${person.id}`}>
|
||||||
className="flex flex-col col-span-1"
|
<span>{person.job}</span>
|
||||||
key={`crew-${person.job}-${person.id}`}
|
|
||||||
>
|
|
||||||
<span className="font-bold">{person.job}</span>
|
|
||||||
<Link href={`/person/${person.id}`}>
|
<Link href={`/person/${person.id}`}>
|
||||||
<a className="text-gray-400 transition duration-300 hover:text-underline hover:text-gray-100">
|
<a className="crew-name">{person.name}</a>
|
||||||
{person.name}
|
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -520,7 +514,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full mt-8 md:w-80 md:mt-0">
|
<div className="media-overview-right">
|
||||||
{data.collection && (
|
{data.collection && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Link href={`/collection/${data.collection.id}`}>
|
<Link href={`/collection/${data.collection.id}`}>
|
||||||
@@ -542,80 +536,65 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-lg shadow">
|
<div className="media-facts">
|
||||||
{(!!data.voteCount ||
|
{(!!data.voteCount ||
|
||||||
(ratingData?.criticsRating && !!ratingData?.criticsScore) ||
|
(ratingData?.criticsRating && !!ratingData?.criticsScore) ||
|
||||||
(ratingData?.audienceRating && !!ratingData?.audienceScore)) && (
|
(ratingData?.audienceRating && !!ratingData?.audienceScore)) && (
|
||||||
<div className="flex items-center justify-center px-4 py-2 border-b border-gray-800 last:border-b-0">
|
<div className="media-ratings">
|
||||||
{ratingData?.criticsRating && !!ratingData?.criticsScore && (
|
{ratingData?.criticsRating && !!ratingData?.criticsScore && (
|
||||||
<>
|
<>
|
||||||
<span className="text-sm">
|
<span className="media-rating">
|
||||||
{ratingData.criticsRating === 'Rotten' ? (
|
{ratingData.criticsRating === 'Rotten' ? (
|
||||||
<RTRotten className="w-6 mr-1" />
|
<RTRotten className="w-6 mr-1" />
|
||||||
) : (
|
) : (
|
||||||
<RTFresh className="w-6 mr-1" />
|
<RTFresh className="w-6 mr-1" />
|
||||||
)}
|
)}
|
||||||
</span>
|
|
||||||
<span className="mr-4 text-sm text-gray-400 last:mr-0">
|
|
||||||
{ratingData.criticsScore}%
|
{ratingData.criticsScore}%
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{ratingData?.audienceRating && !!ratingData?.audienceScore && (
|
{ratingData?.audienceRating && !!ratingData?.audienceScore && (
|
||||||
<>
|
<>
|
||||||
<span className="text-sm">
|
<span className="media-rating">
|
||||||
{ratingData.audienceRating === 'Spilled' ? (
|
{ratingData.audienceRating === 'Spilled' ? (
|
||||||
<RTAudRotten className="w-6 mr-1" />
|
<RTAudRotten className="w-6 mr-1" />
|
||||||
) : (
|
) : (
|
||||||
<RTAudFresh className="w-6 mr-1" />
|
<RTAudFresh className="w-6 mr-1" />
|
||||||
)}
|
)}
|
||||||
</span>
|
|
||||||
<span className="mr-4 text-sm text-gray-400 last:mr-0">
|
|
||||||
{ratingData.audienceScore}%
|
{ratingData.audienceScore}%
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!!data.voteCount && (
|
{!!data.voteCount && (
|
||||||
<>
|
<>
|
||||||
<span className="text-sm">
|
<span className="media-rating">
|
||||||
<TmdbLogo className="w-6 mr-2" />
|
<TmdbLogo className="w-6 mr-2" />
|
||||||
</span>
|
|
||||||
<span className="text-sm text-gray-400">
|
|
||||||
{data.voteAverage}/10
|
{data.voteAverage}/10
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="media-fact">
|
||||||
|
<span>{intl.formatMessage(messages.status)}</span>
|
||||||
|
<span className="media-fact-value">{data.status}</span>
|
||||||
|
</div>
|
||||||
{data.releaseDate && (
|
{data.releaseDate && (
|
||||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
|
<div className="media-fact">
|
||||||
<span className="text-sm">
|
<span>{intl.formatMessage(messages.releasedate)}</span>
|
||||||
{intl.formatMessage(messages.releasedate)}
|
<span className="media-fact-value">
|
||||||
</span>
|
{intl.formatDate(data.releaseDate, {
|
||||||
<span className="flex-1 text-sm text-right text-gray-400">
|
year: 'numeric',
|
||||||
<FormattedDate
|
month: 'long',
|
||||||
value={new Date(data.releaseDate)}
|
day: 'numeric',
|
||||||
year="numeric"
|
})}
|
||||||
month="long"
|
|
||||||
day="numeric"
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
|
|
||||||
<span className="text-sm">
|
|
||||||
{intl.formatMessage(messages.status)}
|
|
||||||
</span>
|
|
||||||
<span className="flex-1 text-sm text-right text-gray-400">
|
|
||||||
{data.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{data.revenue > 0 && (
|
{data.revenue > 0 && (
|
||||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
|
<div className="media-fact">
|
||||||
<span className="text-sm">
|
<span>{intl.formatMessage(messages.revenue)}</span>
|
||||||
{intl.formatMessage(messages.revenue)}
|
<span className="media-fact-value">
|
||||||
</span>
|
|
||||||
<span className="flex-1 text-sm text-right text-gray-400">
|
|
||||||
<FormattedNumber
|
<FormattedNumber
|
||||||
currency="USD"
|
currency="USD"
|
||||||
style="currency"
|
style="currency"
|
||||||
@@ -625,11 +604,9 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data.budget > 0 && (
|
{data.budget > 0 && (
|
||||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
|
<div className="media-fact">
|
||||||
<span className="text-sm">
|
<span>{intl.formatMessage(messages.budget)}</span>
|
||||||
{intl.formatMessage(messages.budget)}
|
<span className="media-fact-value">
|
||||||
</span>
|
|
||||||
<span className="flex-1 text-sm text-right text-gray-400">
|
|
||||||
<FormattedNumber
|
<FormattedNumber
|
||||||
currency="USD"
|
currency="USD"
|
||||||
style="currency"
|
style="currency"
|
||||||
@@ -638,34 +615,48 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data.spokenLanguages.some(
|
{data.originalLanguage && (
|
||||||
(lng) => lng.iso_639_1 === data.originalLanguage
|
<div className="media-fact">
|
||||||
) && (
|
<span>{intl.formatMessage(messages.originallanguage)}</span>
|
||||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
|
<span className="media-fact-value">
|
||||||
<span className="text-sm">
|
<Link
|
||||||
{intl.formatMessage(messages.originallanguage)}
|
href={`/discover/movies/language/${data.originalLanguage}`}
|
||||||
</span>
|
>
|
||||||
<span className="flex-1 text-sm text-right text-gray-400">
|
<a className="hover:underline">
|
||||||
{
|
{intl.formatDisplayName(data.originalLanguage, {
|
||||||
|
type: 'language',
|
||||||
|
fallback: 'none',
|
||||||
|
}) ??
|
||||||
data.spokenLanguages.find(
|
data.spokenLanguages.find(
|
||||||
(lng) => lng.iso_639_1 === data.originalLanguage
|
(lng) => lng.iso_639_1 === data.originalLanguage
|
||||||
)?.name
|
)?.name}
|
||||||
}
|
</a>
|
||||||
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data.productionCompanies[0] && (
|
{data.productionCompanies.length > 0 && (
|
||||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
|
<div className="media-fact">
|
||||||
<span className="text-sm">
|
<span>
|
||||||
{intl.formatMessage(messages.studio)}
|
{intl.formatMessage(messages.studio, {
|
||||||
|
studioCount: data.productionCompanies.length,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 text-sm text-right text-gray-400">
|
<span className="media-fact-value">
|
||||||
{data.productionCompanies[0]?.name}
|
{data.productionCompanies.map((s) => {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/discover/movies/studio/${s.id}`}
|
||||||
|
key={`studio-${s.id}`}
|
||||||
|
>
|
||||||
|
<a className="block hover:underline">{s.name}</a>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
<div className="media-fact">
|
||||||
<div className="mt-4">
|
|
||||||
<ExternalLinkBlock
|
<ExternalLinkBlock
|
||||||
mediaType="movie"
|
mediaType="movie"
|
||||||
tmdbId={data.id}
|
tmdbId={data.id}
|
||||||
@@ -677,12 +668,12 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{data.credits.cast.length > 0 && (
|
{data.credits.cast.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
<div className="slider-header">
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<Link href="/movie/[movieId]/cast" as={`/movie/${data.id}/cast`}>
|
<Link href="/movie/[movieId]/cast" as={`/movie/${data.id}/cast`}>
|
||||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
<a className="slider-title">
|
||||||
<span>{intl.formatMessage(messages.cast)}</span>
|
<span>{intl.formatMessage(messages.cast)}</span>
|
||||||
<svg
|
<svg
|
||||||
className="w-6 h-6 ml-2"
|
className="w-6 h-6 ml-2"
|
||||||
@@ -701,7 +692,6 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
|||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<Slider
|
<Slider
|
||||||
sliderKey="cast"
|
sliderKey="cast"
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
|
|||||||
@@ -8,16 +8,19 @@ const messages = defineMessages({
|
|||||||
'Sends a notification when media is requested and requires approval.',
|
'Sends a notification when media is requested and requires approval.',
|
||||||
mediaapproved: 'Media Approved',
|
mediaapproved: 'Media Approved',
|
||||||
mediaapprovedDescription:
|
mediaapprovedDescription:
|
||||||
'Sends a notification when media is approved.\
|
'Sends a notification when requested media is manually approved.',
|
||||||
By default, automatically approved requests will not trigger notifications.',
|
mediaAutoApproved: 'Media Automatically Approved',
|
||||||
|
mediaAutoApprovedDescription:
|
||||||
|
'Sends a notification when requested media is automatically approved.',
|
||||||
mediaavailable: 'Media Available',
|
mediaavailable: 'Media Available',
|
||||||
mediaavailableDescription:
|
mediaavailableDescription:
|
||||||
'Sends a notification when media becomes available.',
|
'Sends a notification when requested media becomes available.',
|
||||||
mediafailed: 'Media Failed',
|
mediafailed: 'Media Failed',
|
||||||
mediafailedDescription:
|
mediafailedDescription:
|
||||||
'Sends a notification when media fails to be added to Radarr or Sonarr.',
|
'Sends a notification when requested media fails to be added to Radarr or Sonarr.',
|
||||||
mediadeclined: 'Media Declined',
|
mediadeclined: 'Media Declined',
|
||||||
mediadeclinedDescription: 'Sends a notification when a request is declined.',
|
mediadeclinedDescription:
|
||||||
|
'Sends a notification when a media request is declined.',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const hasNotificationType = (
|
export const hasNotificationType = (
|
||||||
@@ -46,6 +49,7 @@ export enum Notification {
|
|||||||
MEDIA_FAILED = 16,
|
MEDIA_FAILED = 16,
|
||||||
TEST_NOTIFICATION = 32,
|
TEST_NOTIFICATION = 32,
|
||||||
MEDIA_DECLINED = 64,
|
MEDIA_DECLINED = 64,
|
||||||
|
MEDIA_AUTO_APPROVED = 128,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationItem {
|
export interface NotificationItem {
|
||||||
@@ -74,6 +78,12 @@ const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
|
|||||||
description: intl.formatMessage(messages.mediarequestedDescription),
|
description: intl.formatMessage(messages.mediarequestedDescription),
|
||||||
value: Notification.MEDIA_PENDING,
|
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',
|
id: 'media-approved',
|
||||||
name: intl.formatMessage(messages.mediaapproved),
|
name: intl.formatMessage(messages.mediaapproved),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { hasPermission } from '../../../server/lib/permissions';
|
import { hasPermission } from '../../../server/lib/permissions';
|
||||||
import { Permission, User } from '../../hooks/useUser';
|
import { Permission, User } from '../../hooks/useUser';
|
||||||
|
import useSettings from '../../hooks/useSettings';
|
||||||
|
|
||||||
export interface PermissionItem {
|
export interface PermissionItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -33,6 +34,8 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
|
|||||||
onUpdate,
|
onUpdate,
|
||||||
parent,
|
parent,
|
||||||
}) => {
|
}) => {
|
||||||
|
const settings = useSettings();
|
||||||
|
|
||||||
const autoApprovePermissions = [
|
const autoApprovePermissions = [
|
||||||
Permission.AUTO_APPROVE,
|
Permission.AUTO_APPROVE,
|
||||||
Permission.AUTO_APPROVE_MOVIE,
|
Permission.AUTO_APPROVE_MOVIE,
|
||||||
@@ -42,34 +45,70 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
|
|||||||
Permission.AUTO_APPROVE_4K_TV,
|
Permission.AUTO_APPROVE_4K_TV,
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
let disabled = false;
|
||||||
<>
|
let checked = hasPermission(option.permission, currentPermission);
|
||||||
<div
|
|
||||||
className={`relative flex items-start first:mt-0 mt-4 ${
|
if (
|
||||||
|
// Permissions for user ID 1 (Plex server owner) cannot be changed
|
||||||
(currentUser && currentUser.id === 1) ||
|
(currentUser && currentUser.id === 1) ||
|
||||||
|
// Admin permission automatically bypasses/grants all other permissions
|
||||||
(option.permission !== Permission.ADMIN &&
|
(option.permission !== Permission.ADMIN &&
|
||||||
hasPermission(Permission.ADMIN, currentPermission)) ||
|
hasPermission(Permission.ADMIN, currentPermission)) ||
|
||||||
|
// Manage Requests permission automatically grants all Auto-Approve permissions
|
||||||
(autoApprovePermissions.includes(option.permission) &&
|
(autoApprovePermissions.includes(option.permission) &&
|
||||||
hasPermission(Permission.MANAGE_REQUESTS, currentPermission)) ||
|
hasPermission(Permission.MANAGE_REQUESTS, currentPermission)) ||
|
||||||
|
// Selecting a parent permission automatically selects all children
|
||||||
(!!parent?.permission &&
|
(!!parent?.permission &&
|
||||||
hasPermission(parent.permission, currentPermission)) ||
|
hasPermission(parent.permission, currentPermission))
|
||||||
|
) {
|
||||||
|
disabled = true;
|
||||||
|
checked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
// Non-Admin users cannot modify the Admin permission
|
||||||
(actingUser &&
|
(actingUser &&
|
||||||
!hasPermission(Permission.ADMIN, actingUser.permissions) &&
|
!hasPermission(Permission.ADMIN, actingUser.permissions) &&
|
||||||
option.permission === Permission.ADMIN) ||
|
option.permission === Permission.ADMIN) ||
|
||||||
|
// Users without the Manage Settings permission cannot modify/grant that permission
|
||||||
(actingUser &&
|
(actingUser &&
|
||||||
!hasPermission(
|
!hasPermission(Permission.MANAGE_SETTINGS, actingUser.permissions) &&
|
||||||
Permission.MANAGE_SETTINGS,
|
option.permission === Permission.MANAGE_SETTINGS)
|
||||||
actingUser.permissions
|
) {
|
||||||
) &&
|
disabled = true;
|
||||||
option.permission === Permission.MANAGE_SETTINGS) ||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
// Some permissions are dependent on others; check requirements are fulfilled
|
||||||
(option.requires &&
|
(option.requires &&
|
||||||
!option.requires.every((requirement) =>
|
!option.requires.every((requirement) =>
|
||||||
hasPermission(requirement.permissions, currentPermission, {
|
hasPermission(requirement.permissions, currentPermission, {
|
||||||
type: requirement.type ?? 'and',
|
type: requirement.type ?? 'and',
|
||||||
})
|
})
|
||||||
))
|
)) ||
|
||||||
? 'opacity-50'
|
// 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 (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`relative flex items-start first:mt-0 mt-4 ${
|
||||||
|
disabled ? 'opacity-50' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center h-6">
|
<div className="flex items-center h-6">
|
||||||
@@ -77,30 +116,7 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
|
|||||||
id={option.id}
|
id={option.id}
|
||||||
name="permissions"
|
name="permissions"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
disabled={
|
disabled={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',
|
|
||||||
})
|
|
||||||
))
|
|
||||||
}
|
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
onUpdate(
|
onUpdate(
|
||||||
hasPermission(option.permission, currentPermission)
|
hasPermission(option.permission, currentPermission)
|
||||||
@@ -108,26 +124,11 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
|
|||||||
: currentPermission + option.permission
|
: currentPermission + option.permission
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
checked={
|
checked={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',
|
|
||||||
})
|
|
||||||
))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 text-sm leading-6">
|
<div className="ml-3 text-sm leading-6">
|
||||||
<label htmlFor={option.id} className="block font-medium">
|
<label htmlFor={option.id} className="block font-medium text-white">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span>{option.name}</span>
|
<span>{option.name}</span>
|
||||||
<span className="text-gray-500">{option.description}</span>
|
<span className="text-gray-500">{option.description}</span>
|
||||||
|
|||||||
@@ -37,8 +37,10 @@ const PersonCard: React.FC<PersonCardProps> = ({
|
|||||||
<div
|
<div
|
||||||
className={`relative ${
|
className={`relative ${
|
||||||
canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'
|
canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'
|
||||||
} rounded-lg text-white shadow-lg transition ease-in-out duration-150 cursor-pointer transform-gpu ${
|
} rounded-xl text-white shadow transition ease-in-out duration-150 cursor-pointer transform-gpu ring-1 ${
|
||||||
isHovered ? 'bg-gray-600 scale-105' : 'bg-gray-700 scale-100'
|
isHovered
|
||||||
|
? 'bg-gray-700 scale-105 ring-gray-500'
|
||||||
|
: 'bg-gray-800 scale-100 ring-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div style={{ paddingBottom: '150%' }}>
|
<div style={{ paddingBottom: '150%' }}>
|
||||||
@@ -47,7 +49,7 @@ const PersonCard: React.FC<PersonCardProps> = ({
|
|||||||
{profilePath ? (
|
{profilePath ? (
|
||||||
<img
|
<img
|
||||||
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`}
|
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`}
|
||||||
className="object-cover w-3/4 h-full bg-center bg-cover rounded-full"
|
className="object-cover w-3/4 h-full bg-center bg-cover rounded-full ring-1 ring-gray-700"
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -79,7 +81,11 @@ const PersonCard: React.FC<PersonCardProps> = ({
|
|||||||
{subName}
|
{subName}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-12 rounded-b-lg bg-gradient-to-t from-gray-700" />
|
<div
|
||||||
|
className={`absolute bottom-0 left-0 right-0 h-12 rounded-b-xl bg-gradient-to-t ${
|
||||||
|
isHovered ? 'from-gray-800' : 'from-gray-900'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import { groupBy } from 'lodash';
|
|||||||
import PageTitle from '../Common/PageTitle';
|
import PageTitle from '../Common/PageTitle';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
appearsin: 'Appears in',
|
appearsin: 'Appearances',
|
||||||
crewmember: 'Crew Member',
|
crewmember: 'Crew',
|
||||||
ascharacter: 'as {character}',
|
ascharacter: 'as {character}',
|
||||||
nobiography: 'No biography available.',
|
nobiography: 'No biography available.',
|
||||||
});
|
});
|
||||||
@@ -85,13 +85,11 @@ const PersonDetails: React.FC = () => {
|
|||||||
|
|
||||||
const cast = (sortedCast ?? []).length > 0 && (
|
const cast = (sortedCast ?? []).length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="relative z-10 mt-6 mb-4 md:flex md:items-center md:justify-between">
|
<div className="slider-header">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="slider-title">
|
||||||
<div className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
|
||||||
<span>{intl.formatMessage(messages.appearsin)}</span>
|
<span>{intl.formatMessage(messages.appearsin)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<ul className="cardList">
|
<ul className="cardList">
|
||||||
{sortedCast?.map((media, index) => {
|
{sortedCast?.map((media, index) => {
|
||||||
return (
|
return (
|
||||||
@@ -127,13 +125,11 @@ const PersonDetails: React.FC = () => {
|
|||||||
|
|
||||||
const crew = (sortedCrew ?? []).length > 0 && (
|
const crew = (sortedCrew ?? []).length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="relative z-10 mt-6 mb-4 md:flex md:items-center md:justify-between">
|
<div className="slider-header">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="slider-title">
|
||||||
<div className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
|
||||||
<span>{intl.formatMessage(messages.crewmember)}</span>
|
<span>{intl.formatMessage(messages.crewmember)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<ul className="cardList">
|
<ul className="cardList">
|
||||||
{sortedCrew?.map((media, index) => {
|
{sortedCrew?.map((media, index) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import type { MediaRequest } from '../../../server/entity/MediaRequest';
|
import type { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||||
import { FormattedDate, useIntl, defineMessages } from 'react-intl';
|
import { useIntl, defineMessages } from 'react-intl';
|
||||||
import Badge from '../Common/Badge';
|
import Badge from '../Common/Badge';
|
||||||
import { MediaRequestStatus } from '../../../server/constants/media';
|
import { MediaRequestStatus } from '../../../server/constants/media';
|
||||||
import Button from '../Common/Button';
|
import Button from '../Common/Button';
|
||||||
@@ -10,7 +10,7 @@ import RequestModal from '../RequestModal';
|
|||||||
import useRequestOverride from '../../hooks/useRequestOverride';
|
import useRequestOverride from '../../hooks/useRequestOverride';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
seasons: 'Seasons',
|
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
|
||||||
requestoverrides: 'Request Overrides',
|
requestoverrides: 'Request Overrides',
|
||||||
server: 'Server',
|
server: 'Server',
|
||||||
profilechanged: 'Profile Changed',
|
profilechanged: 'Profile Changed',
|
||||||
@@ -65,12 +65,12 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
|||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="px-4 py-4">
|
<div className="px-4 py-4 text-gray-300">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-col items-center flex-1 min-w-0 mr-6 text-sm leading-5 text-gray-300">
|
<div className="flex-col items-center flex-1 min-w-0 mr-6 text-sm leading-5">
|
||||||
<div className="flex mb-1 flex-nowrap white">
|
<div className="flex mb-1 flex-nowrap white">
|
||||||
<svg
|
<svg
|
||||||
className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
|
className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -88,7 +88,7 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
|||||||
{request.modifiedBy && (
|
{request.modifiedBy && (
|
||||||
<div className="flex flex-nowrap">
|
<div className="flex flex-nowrap">
|
||||||
<svg
|
<svg
|
||||||
className="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
|
className="flex-shrink-0 mr-1.5 h-5 w-5"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -191,7 +191,7 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-2 sm:flex sm:justify-between">
|
<div className="mt-2 sm:flex sm:justify-between">
|
||||||
<div className="sm:flex">
|
<div className="sm:flex">
|
||||||
<div className="flex items-center mr-6 text-sm leading-5 text-gray-300">
|
<div className="flex items-center mr-6 text-sm leading-5">
|
||||||
{request.is4k && (
|
{request.is4k && (
|
||||||
<span className="mr-1">
|
<span className="mr-1">
|
||||||
<Badge badgeType="warning">4K</Badge>
|
<Badge badgeType="warning">4K</Badge>
|
||||||
@@ -214,9 +214,9 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center mt-2 text-sm leading-5 text-gray-300 sm:mt-0">
|
<div className="flex items-center mt-2 text-sm leading-5 sm:mt-0">
|
||||||
<svg
|
<svg
|
||||||
className="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
|
className="flex-shrink-0 mr-1.5 h-5 w-5"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
@@ -228,13 +228,21 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>
|
<span>
|
||||||
<FormattedDate value={request.createdAt} />
|
{intl.formatDate(request.createdAt, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(request.seasons ?? []).length > 0 && (
|
{(request.seasons ?? []).length > 0 && (
|
||||||
<div className="flex flex-col mt-2 text-sm">
|
<div className="flex flex-col mt-2 text-sm">
|
||||||
<div className="mb-2">{intl.formatMessage(messages.seasons)}</div>
|
<div className="mb-1 font-medium">
|
||||||
|
{intl.formatMessage(messages.seasons, {
|
||||||
|
seasonCount: request.seasons.length,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{request.seasons.map((season) => (
|
{request.seasons.map((season) => (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import type { TvDetails } from '../../../server/models/Tv';
|
|||||||
import type { MovieDetails } from '../../../server/models/Movie';
|
import type { MovieDetails } from '../../../server/models/Movie';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { LanguageContext } from '../../context/LanguageContext';
|
import { LanguageContext } from '../../context/LanguageContext';
|
||||||
import { MediaRequestStatus } from '../../../server/constants/media';
|
import {
|
||||||
|
MediaRequestStatus,
|
||||||
|
MediaStatus,
|
||||||
|
} from '../../../server/constants/media';
|
||||||
import Badge from '../Common/Badge';
|
import Badge from '../Common/Badge';
|
||||||
import { useUser, Permission } from '../../hooks/useUser';
|
import { useUser, Permission } from '../../hooks/useUser';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -17,7 +20,8 @@ import globalMessages from '../../i18n/globalMessages';
|
|||||||
import StatusBadge from '../StatusBadge';
|
import StatusBadge from '../StatusBadge';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
seasons: 'Seasons',
|
status: 'Status',
|
||||||
|
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
|
||||||
all: 'All',
|
all: 'All',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -27,7 +31,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
|||||||
|
|
||||||
const RequestCardPlaceholder: React.FC = () => {
|
const RequestCardPlaceholder: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<div className="relative p-4 bg-gray-700 rounded-lg w-72 sm:w-96 animate-pulse">
|
<div className="relative p-4 bg-gray-700 rounded-xl w-72 sm:w-96 animate-pulse">
|
||||||
<div className="w-20 sm:w-28">
|
<div className="w-20 sm:w-28">
|
||||||
<div className="w-full" style={{ paddingBottom: '150%' }} />
|
<div className="w-full" style={{ paddingBottom: '150%' }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -94,60 +98,49 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative flex p-4 text-gray-400 bg-gray-800 bg-center bg-cover rounded-md w-72 sm:w-96"
|
className="relative flex p-4 text-gray-400 bg-gray-700 bg-center bg-cover shadow rounded-xl w-72 sm:w-96 ring-1 ring-gray-700"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath})`,
|
backgroundImage: `linear-gradient(135deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 75%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath})`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col flex-1 min-w-0 pr-4">
|
<div className="flex flex-col flex-1 min-w-0 pr-4">
|
||||||
<h2 className="overflow-hidden text-base text-white cursor-pointer sm:text-lg overflow-ellipsis whitespace-nowrap hover:underline">
|
|
||||||
<Link
|
<Link
|
||||||
href={request.type === 'movie' ? '/movie/[movieId]' : '/tv/[tvId]'}
|
href={
|
||||||
as={
|
|
||||||
request.type === 'movie'
|
request.type === 'movie'
|
||||||
? `/movie/${request.media.tmdbId}`
|
? `/movie/${requestData.media.tmdbId}`
|
||||||
: `/tv/${request.media.tmdbId}`
|
: `/tv/${requestData.media.tmdbId}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<a className="pb-0.5 sm:pb-1 overflow-hidden text-base text-white cursor-pointer sm:text-lg overflow-ellipsis whitespace-nowrap hover:underline">
|
||||||
{isMovie(title) ? title.title : title.name}
|
{isMovie(title) ? title.title : title.name}
|
||||||
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
</h2>
|
<div className="card-field">
|
||||||
<Link href={`/users/${requestData.requestedBy.id}`}>
|
<Link href={`/users/${requestData.requestedBy.id}`}>
|
||||||
<a className="flex items-center group">
|
<a className="flex items-center group">
|
||||||
<img
|
<img
|
||||||
src={requestData.requestedBy.avatar}
|
src={requestData.requestedBy.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-4 mr-1 rounded-full sm:mr-2 sm:w-5"
|
className="avatar-sm"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs truncate sm:text-sm group-hover:underline">
|
<span className="truncate group-hover:underline">
|
||||||
{requestData.requestedBy.displayName}
|
{requestData.requestedBy.displayName}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
{requestData.media.status && (
|
|
||||||
<div className="mt-1 sm:mt-2">
|
|
||||||
<StatusBadge
|
|
||||||
status={
|
|
||||||
requestData.is4k
|
|
||||||
? requestData.media.status4k
|
|
||||||
: requestData.media.status
|
|
||||||
}
|
|
||||||
is4k={requestData.is4k}
|
|
||||||
inProgress={
|
|
||||||
(
|
|
||||||
requestData.media[
|
|
||||||
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
|
|
||||||
] ?? []
|
|
||||||
).length > 0
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{!isMovie(title) && request.seasons.length > 0 && (
|
||||||
{request.seasons.length > 0 && (
|
<div className="sm:flex items-center my-0.5 sm:my-1 text-sm hidden">
|
||||||
<div className="items-center hidden mt-2 text-sm sm:flex">
|
<span className="mr-2 font-medium">
|
||||||
<span className="mr-2">{intl.formatMessage(messages.seasons)}</span>
|
{intl.formatMessage(messages.seasons, {
|
||||||
{!isMovie(title) &&
|
seasonCount:
|
||||||
title.seasons.filter((season) => season.seasonNumber !== 0)
|
title.seasons.filter((season) => season.seasonNumber !== 0)
|
||||||
|
.length === request.seasons.length
|
||||||
|
? 0
|
||||||
|
: request.seasons.length,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{title.seasons.filter((season) => season.seasonNumber !== 0)
|
||||||
.length === request.seasons.length ? (
|
.length === request.seasons.length ? (
|
||||||
<span className="mr-2 uppercase">
|
<span className="mr-2 uppercase">
|
||||||
<Badge>{intl.formatMessage(messages.all)}</Badge>
|
<Badge>{intl.formatMessage(messages.all)}</Badge>
|
||||||
@@ -163,6 +156,34 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex items-center mt-2 text-sm sm:mt-1">
|
||||||
|
<span className="hidden mr-2 font-medium sm:block">
|
||||||
|
{intl.formatMessage(messages.status)}
|
||||||
|
</span>
|
||||||
|
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
|
||||||
|
MediaStatus.UNKNOWN ||
|
||||||
|
requestData.status === MediaRequestStatus.DECLINED ? (
|
||||||
|
<Badge badgeType="danger">
|
||||||
|
{requestData.status === MediaRequestStatus.DECLINED
|
||||||
|
? intl.formatMessage(globalMessages.declined)
|
||||||
|
: intl.formatMessage(globalMessages.failed)}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<StatusBadge
|
||||||
|
status={
|
||||||
|
requestData.media[requestData.is4k ? 'status4k' : 'status']
|
||||||
|
}
|
||||||
|
inProgress={
|
||||||
|
(
|
||||||
|
requestData.media[
|
||||||
|
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
|
||||||
|
] ?? []
|
||||||
|
).length > 0
|
||||||
|
}
|
||||||
|
is4k={requestData.is4k}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{requestData.status === MediaRequestStatus.PENDING &&
|
{requestData.status === MediaRequestStatus.PENDING &&
|
||||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||||
<div className="flex items-end flex-1">
|
<div className="flex items-end flex-1">
|
||||||
@@ -215,15 +236,14 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 w-20 sm:w-28">
|
|
||||||
<Link
|
<Link
|
||||||
href={request.type === 'movie' ? '/movie/[movieId]' : '/tv/[tvId]'}
|
href={
|
||||||
as={
|
|
||||||
request.type === 'movie'
|
request.type === 'movie'
|
||||||
? `/movie/${request.media.tmdbId}`
|
? `/movie/${requestData.media.tmdbId}`
|
||||||
: `/tv/${request.media.tmdbId}`
|
: `/tv/${requestData.media.tmdbId}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<a className="flex-shrink-0 w-20 sm:w-28">
|
||||||
<img
|
<img
|
||||||
src={
|
src={
|
||||||
title.posterPath
|
title.posterPath
|
||||||
@@ -233,9 +253,9 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
|
|||||||
alt=""
|
alt=""
|
||||||
className="w-20 transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer sm:w-28 transform-gpu hover:scale-105 hover:shadow-md"
|
className="w-20 transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer sm:w-28 transform-gpu hover:scale-105 hover:shadow-md"
|
||||||
/>
|
/>
|
||||||
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import React, { useContext, useState } from 'react';
|
import React, { useContext, useState } from 'react';
|
||||||
import { useInView } from 'react-intersection-observer';
|
import { useInView } from 'react-intersection-observer';
|
||||||
import type { MediaRequest } from '../../../../server/entity/MediaRequest';
|
import type { MediaRequest } from '../../../../server/entity/MediaRequest';
|
||||||
import {
|
import { useIntl, FormattedRelativeTime, defineMessages } from 'react-intl';
|
||||||
useIntl,
|
|
||||||
FormattedDate,
|
|
||||||
FormattedRelativeTime,
|
|
||||||
defineMessages,
|
|
||||||
} from 'react-intl';
|
|
||||||
import { useUser, Permission } from '../../../hooks/useUser';
|
import { useUser, Permission } from '../../../hooks/useUser';
|
||||||
import { LanguageContext } from '../../../context/LanguageContext';
|
import { LanguageContext } from '../../../context/LanguageContext';
|
||||||
import type { MovieDetails } from '../../../../server/models/Movie';
|
import type { MovieDetails } from '../../../../server/models/Movie';
|
||||||
@@ -14,7 +9,6 @@ import type { TvDetails } from '../../../../server/models/Tv';
|
|||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import Badge from '../../Common/Badge';
|
import Badge from '../../Common/Badge';
|
||||||
import StatusBadge from '../../StatusBadge';
|
import StatusBadge from '../../StatusBadge';
|
||||||
import Table from '../../Common/Table';
|
|
||||||
import {
|
import {
|
||||||
MediaRequestStatus,
|
MediaRequestStatus,
|
||||||
MediaStatus,
|
MediaStatus,
|
||||||
@@ -25,11 +19,18 @@ import globalMessages from '../../../i18n/globalMessages';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import RequestModal from '../../RequestModal';
|
import RequestModal from '../../RequestModal';
|
||||||
|
import ConfirmButton from '../../Common/ConfirmButton';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
seasons: 'Seasons',
|
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
|
||||||
|
all: 'All',
|
||||||
notavailable: 'N/A',
|
notavailable: 'N/A',
|
||||||
failedretry: 'Something went wrong while retrying the request.',
|
failedretry: 'Something went wrong while retrying the request.',
|
||||||
|
areyousure: 'Are you sure?',
|
||||||
|
status: 'Status',
|
||||||
|
requested: 'Requested',
|
||||||
|
modified: 'Modified',
|
||||||
|
modifieduserdate: '{date} by {user}',
|
||||||
});
|
});
|
||||||
|
|
||||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||||
@@ -101,22 +102,24 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
|||||||
|
|
||||||
if (!title && !error) {
|
if (!title && !error) {
|
||||||
return (
|
return (
|
||||||
<tr className="w-full h-24 animate-pulse" ref={ref}>
|
<div
|
||||||
<td colSpan={6}></td>
|
className="w-full h-64 bg-gray-800 rounded-xl lg:h-32 animate-pulse"
|
||||||
</tr>
|
ref={ref}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!title || !requestData) {
|
if (!title || !requestData) {
|
||||||
return (
|
return (
|
||||||
<tr className="w-full h-24 animate-pulse">
|
<div
|
||||||
<td colSpan={6}></td>
|
className="w-full h-64 bg-gray-800 rounded-xl lg:h-32 animate-pulse"
|
||||||
</tr>
|
ref={ref}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="relative w-full h-24 p-2">
|
<>
|
||||||
<RequestModal
|
<RequestModal
|
||||||
show={showEditModal}
|
show={showEditModal}
|
||||||
tmdbId={request.media.tmdbId}
|
tmdbId={request.media.tmdbId}
|
||||||
@@ -129,28 +132,17 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
|||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Table.TD>
|
<div className="relative flex flex-col justify-between w-full py-4 overflow-hidden text-gray-400 bg-gray-800 shadow-md ring-1 ring-gray-700 rounded-xl lg:h-32 lg:flex-row">
|
||||||
<div className="flex items-center">
|
<div
|
||||||
<Link
|
className="absolute inset-0 z-0 w-full bg-center bg-cover lg:w-2/3"
|
||||||
href={
|
style={{
|
||||||
request.type === 'movie'
|
backgroundImage: title.backdropPath
|
||||||
? `/movie/${request.media.tmdbId}`
|
? `linear-gradient(90deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath})`
|
||||||
: `/tv/${request.media.tmdbId}`
|
: undefined,
|
||||||
}
|
}}
|
||||||
>
|
|
||||||
<a className="flex-shrink-0 hidden mr-4 sm:block">
|
|
||||||
<img
|
|
||||||
src={
|
|
||||||
title.posterPath
|
|
||||||
? `//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
|
||||||
: '/images/overseerr_poster_not_found.png'
|
|
||||||
}
|
|
||||||
alt=""
|
|
||||||
className="w-12 transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer transform-gpu hover:scale-105 hover:shadow-md"
|
|
||||||
/>
|
/>
|
||||||
</a>
|
<div className="relative flex flex-col justify-between w-full overflow-hidden sm:flex-row">
|
||||||
</Link>
|
<div className="relative z-10 flex items-center w-full pl-4 pr-4 overflow-hidden lg:w-1/2 xl:w-7/12 2xl:w-2/3 sm:pr-0">
|
||||||
<div className="flex-shrink overflow-hidden">
|
|
||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
requestData.type === 'movie'
|
requestData.type === 'movie'
|
||||||
@@ -158,28 +150,66 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
|||||||
: `/tv/${requestData.media.tmdbId}`
|
: `/tv/${requestData.media.tmdbId}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<a className="min-w-0 mr-2 text-xl text-white truncate hover:underline">
|
<a className="flex-shrink-0 w-12 h-auto overflow-hidden transition duration-300 scale-100 rounded-md lg:w-14 transform-gpu hover:scale-105">
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
title.posterPath
|
||||||
|
? `//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||||
|
: '/images/overseerr_poster_not_found.png'
|
||||||
|
}
|
||||||
|
alt=""
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-col justify-center pl-2 overflow-hidden lg:pl-4">
|
||||||
|
<div className="card-field">
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
requestData.type === 'movie'
|
||||||
|
? `/movie/${requestData.media.tmdbId}`
|
||||||
|
: `/tv/${requestData.media.tmdbId}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<a className="min-w-0 mr-2 text-lg text-white truncate lg:text-xl hover:underline">
|
||||||
{isMovie(title) ? title.title : title.name}
|
{isMovie(title) ? title.title : title.name}
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="card-field">
|
||||||
<Link href={`/users/${requestData.requestedBy.id}`}>
|
<Link href={`/users/${requestData.requestedBy.id}`}>
|
||||||
<a className="flex items-center mt-1">
|
<a className="flex items-center group">
|
||||||
<img
|
<img
|
||||||
src={requestData.requestedBy.avatar}
|
src={requestData.requestedBy.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-5 mr-2 rounded-full"
|
className="avatar-sm"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm hover:underline">
|
<span className="text-sm text-gray-300 truncate group-hover:underline">
|
||||||
{requestData.requestedBy.displayName}
|
{requestData.requestedBy.displayName}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
{requestData.seasons.length > 0 && (
|
</div>
|
||||||
<div className="items-center hidden mt-2 text-sm sm:flex">
|
{!isMovie(title) && request.seasons.length > 0 && (
|
||||||
<span className="mr-2">
|
<div className="card-field">
|
||||||
{intl.formatMessage(messages.seasons)}
|
<span className="card-field-name">
|
||||||
|
{intl.formatMessage(messages.seasons, {
|
||||||
|
seasonCount:
|
||||||
|
title.seasons.filter(
|
||||||
|
(season) => season.seasonNumber !== 0
|
||||||
|
).length === request.seasons.length
|
||||||
|
? 0
|
||||||
|
: request.seasons.length,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
{requestData.seasons.map((season) => (
|
{title.seasons.filter((season) => season.seasonNumber !== 0)
|
||||||
|
.length === request.seasons.length ? (
|
||||||
|
<span className="mr-2 uppercase">
|
||||||
|
<Badge>{intl.formatMessage(messages.all)}</Badge>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex overflow-x-scroll hide-scrollbar flex-nowrap">
|
||||||
|
{request.seasons.map((season) => (
|
||||||
<span key={`season-${season.id}`} className="mr-2">
|
<span key={`season-${season.id}`} className="mr-2">
|
||||||
<Badge>{season.seasonNumber}</Badge>
|
<Badge>{season.seasonNumber}</Badge>
|
||||||
</span>
|
</span>
|
||||||
@@ -187,9 +217,14 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Table.TD>
|
</div>
|
||||||
<Table.TD>
|
<div className="z-10 flex flex-col justify-between w-full pr-4 mt-4 ml-4 text-sm sm:ml-2 sm:mt-0 lg:flex-1 lg:pr-0">
|
||||||
|
<div className="card-field">
|
||||||
|
<span className="card-field-name">
|
||||||
|
{intl.formatMessage(messages.status)}
|
||||||
|
</span>
|
||||||
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
|
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
|
||||||
MediaStatus.UNKNOWN ||
|
MediaStatus.UNKNOWN ||
|
||||||
requestData.status === MediaRequestStatus.DECLINED ? (
|
requestData.status === MediaRequestStatus.DECLINED ? (
|
||||||
@@ -200,7 +235,9 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
|||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
status={requestData.media[requestData.is4k ? 'status4k' : 'status']}
|
status={
|
||||||
|
requestData.media[requestData.is4k ? 'status4k' : 'status']
|
||||||
|
}
|
||||||
inProgress={
|
inProgress={
|
||||||
(
|
(
|
||||||
requestData.media[
|
requestData.media[
|
||||||
@@ -209,58 +246,77 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
|||||||
).length > 0
|
).length > 0
|
||||||
}
|
}
|
||||||
is4k={requestData.is4k}
|
is4k={requestData.is4k}
|
||||||
|
plexUrl={requestData.media.plexUrl}
|
||||||
|
plexUrl4k={requestData.media.plexUrl4k}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Table.TD>
|
</div>
|
||||||
<Table.TD>
|
<div className="card-field">
|
||||||
<div className="flex flex-col">
|
<span className="card-field-name">
|
||||||
<span className="text-sm text-gray-300">
|
{intl.formatMessage(messages.requested)}
|
||||||
<FormattedDate value={requestData.createdAt} />
|
</span>
|
||||||
|
<span className="text-gray-300">
|
||||||
|
{intl.formatDate(requestData.createdAt, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Table.TD>
|
<div className="card-field">
|
||||||
<Table.TD>
|
<span className="card-field-name">
|
||||||
<div className="flex flex-col">
|
{intl.formatMessage(messages.modified)}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">
|
||||||
{requestData.modifiedBy ? (
|
{requestData.modifiedBy ? (
|
||||||
<span className="text-sm text-gray-300">
|
<span className="flex text-sm text-gray-300">
|
||||||
<div className="flex items-center">
|
{intl.formatMessage(messages.modifieduserdate, {
|
||||||
<img
|
date: (
|
||||||
src={requestData.modifiedBy.avatar}
|
|
||||||
alt=""
|
|
||||||
className="w-5 mr-2 rounded-full"
|
|
||||||
/>
|
|
||||||
<span className="text-sm">
|
|
||||||
{requestData.modifiedBy.displayName} (
|
|
||||||
<FormattedRelativeTime
|
<FormattedRelativeTime
|
||||||
value={Math.floor(
|
value={Math.floor(
|
||||||
(new Date(requestData.updatedAt).getTime() - Date.now()) /
|
(new Date(requestData.updatedAt).getTime() -
|
||||||
|
Date.now()) /
|
||||||
1000
|
1000
|
||||||
)}
|
)}
|
||||||
updateIntervalInSeconds={1}
|
updateIntervalInSeconds={1}
|
||||||
/>
|
/>
|
||||||
)
|
),
|
||||||
|
user: (
|
||||||
|
<Link href={`/users/${requestData.modifiedBy.id}`}>
|
||||||
|
<a className="flex items-center group">
|
||||||
|
<img
|
||||||
|
src={requestData.modifiedBy.avatar}
|
||||||
|
alt=""
|
||||||
|
className="ml-1.5 avatar-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-sm truncate group-hover:underline">
|
||||||
|
{requestData.modifiedBy.displayName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</a>
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-gray-300">N/A</span>
|
<span className="text-sm text-gray-300">N/A</span>
|
||||||
)}
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Table.TD>
|
</div>
|
||||||
<Table.TD alignText="right">
|
</div>
|
||||||
|
<div className="z-10 flex flex-col justify-between w-full pl-4 pr-4 mt-4 space-y-2 lg:mt-0 lg:items-end lg:justify-around lg:w-96 lg:pl-0">
|
||||||
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
|
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
|
||||||
MediaStatus.UNKNOWN &&
|
MediaStatus.UNKNOWN &&
|
||||||
requestData.status !== MediaRequestStatus.DECLINED &&
|
requestData.status !== MediaRequestStatus.DECLINED &&
|
||||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||||
<Button
|
<Button
|
||||||
className="mr-2"
|
className="w-full"
|
||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
buttonSize="sm"
|
|
||||||
disabled={isRetrying}
|
disabled={isRetrying}
|
||||||
onClick={() => retryRequest()}
|
onClick={() => retryRequest()}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4 mr-0 sm:mr-1"
|
className="w-5 h-5 mr-1"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -270,20 +326,20 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
|||||||
<path d="M0 0h24v24H0z" fill="none" />
|
<path d="M0 0h24v24H0z" fill="none" />
|
||||||
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z" />
|
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="hidden sm:block">
|
<span className="block">
|
||||||
{intl.formatMessage(globalMessages.retry)}
|
{intl.formatMessage(globalMessages.retry)}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{requestData.status !== MediaRequestStatus.PENDING &&
|
{requestData.status !== MediaRequestStatus.PENDING &&
|
||||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||||
<Button
|
<ConfirmButton
|
||||||
buttonType="danger"
|
|
||||||
buttonSize="sm"
|
|
||||||
onClick={() => deleteRequest()}
|
onClick={() => deleteRequest()}
|
||||||
|
confirmText={intl.formatMessage(messages.areyousure)}
|
||||||
|
className="w-full"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4 mr-0 sm:mr-1"
|
className="w-5 h-5 mr-1"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -294,22 +350,23 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
|||||||
clipRule="evenodd"
|
clipRule="evenodd"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="hidden sm:block">
|
<span className="block">
|
||||||
{intl.formatMessage(globalMessages.delete)}
|
{intl.formatMessage(globalMessages.delete)}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</ConfirmButton>
|
||||||
)}
|
)}
|
||||||
{requestData.status === MediaRequestStatus.PENDING &&
|
{requestData.status === MediaRequestStatus.PENDING &&
|
||||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||||
<>
|
<>
|
||||||
<span className="mr-2">
|
<div className="flex flex-row w-full space-x-2">
|
||||||
|
<span className="w-full">
|
||||||
<Button
|
<Button
|
||||||
|
className="w-full"
|
||||||
buttonType="success"
|
buttonType="success"
|
||||||
buttonSize="sm"
|
|
||||||
onClick={() => modifyRequest('approve')}
|
onClick={() => modifyRequest('approve')}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4 mr-0 sm:mr-1"
|
className="w-5 h-5 mr-1"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -320,19 +377,19 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
|||||||
clipRule="evenodd"
|
clipRule="evenodd"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="hidden sm:block">
|
<span className="block">
|
||||||
{intl.formatMessage(globalMessages.approve)}
|
{intl.formatMessage(globalMessages.approve)}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
<span className="mr-2">
|
<span className="w-full">
|
||||||
<Button
|
<Button
|
||||||
|
className="w-full"
|
||||||
buttonType="danger"
|
buttonType="danger"
|
||||||
buttonSize="sm"
|
|
||||||
onClick={() => modifyRequest('decline')}
|
onClick={() => modifyRequest('decline')}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4 mr-0 sm:mr-1"
|
className="w-5 h-5 mr-1"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -343,34 +400,36 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
|||||||
clipRule="evenodd"
|
clipRule="evenodd"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="hidden sm:block">
|
<span className="block">
|
||||||
{intl.formatMessage(globalMessages.decline)}
|
{intl.formatMessage(globalMessages.decline)}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
</div>
|
||||||
|
<span className="w-full">
|
||||||
<Button
|
<Button
|
||||||
|
className="w-full"
|
||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
buttonSize="sm"
|
|
||||||
onClick={() => setShowEditModal(true)}
|
onClick={() => setShowEditModal(true)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4 mr-0 sm:mr-1"
|
className="w-5 h-5 mr-1"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="hidden sm:block">
|
<span className="block">
|
||||||
{intl.formatMessage(globalMessages.edit)}
|
{intl.formatMessage(globalMessages.edit)}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Table.TD>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces';
|
import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces';
|
||||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||||
import RequestItem from './RequestItem';
|
import RequestItem from './RequestItem';
|
||||||
import Header from '../Common/Header';
|
import Header from '../Common/Header';
|
||||||
import Table from '../Common/Table';
|
|
||||||
import Button from '../Common/Button';
|
import Button from '../Common/Button';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
import PageTitle from '../Common/PageTitle';
|
import PageTitle from '../Common/PageTitle';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
requests: 'Requests',
|
requests: 'Requests',
|
||||||
mediaInfo: 'Media Info',
|
|
||||||
status: 'Status',
|
|
||||||
requestedAt: 'Requested At',
|
|
||||||
modifiedBy: 'Last Modified By',
|
|
||||||
showingresults:
|
showingresults:
|
||||||
'Showing <strong>{from}</strong> to <strong>{to}</strong> of <strong>{total}</strong> results',
|
'Showing <strong>{from}</strong> to <strong>{to}</strong> of <strong>{total}</strong> results',
|
||||||
resultsperpage: 'Display {pageSize} results per page',
|
resultsperpage: 'Display {pageSize} results per page',
|
||||||
@@ -35,17 +31,46 @@ type Filter = 'all' | 'pending' | 'approved' | 'processing' | 'available';
|
|||||||
type Sort = 'added' | 'modified';
|
type Sort = 'added' | 'modified';
|
||||||
|
|
||||||
const RequestList: React.FC = () => {
|
const RequestList: React.FC = () => {
|
||||||
|
const router = useRouter();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [pageIndex, setPageIndex] = useState(0);
|
|
||||||
const [currentFilter, setCurrentFilter] = useState<Filter>('pending');
|
const [currentFilter, setCurrentFilter] = useState<Filter>('pending');
|
||||||
const [currentSort, setCurrentSort] = useState<Sort>('added');
|
const [currentSort, setCurrentSort] = useState<Sort>('added');
|
||||||
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
|
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
|
||||||
|
|
||||||
|
const page = router.query.page ? Number(router.query.page) : 1;
|
||||||
|
const pageIndex = page - 1;
|
||||||
|
|
||||||
const { data, error, revalidate } = useSWR<RequestResultsResponse>(
|
const { data, error, revalidate } = useSWR<RequestResultsResponse>(
|
||||||
`/api/v1/request?take=${currentPageSize}&skip=${
|
`/api/v1/request?take=${currentPageSize}&skip=${
|
||||||
pageIndex * currentPageSize
|
pageIndex * currentPageSize
|
||||||
}&filter=${currentFilter}&sort=${currentSort}`
|
}&filter=${currentFilter}&sort=${currentSort}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Restore last set filter values on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
const filterString = window.localStorage.getItem('rl-filter-settings');
|
||||||
|
|
||||||
|
if (filterString) {
|
||||||
|
const filterSettings = JSON.parse(filterString);
|
||||||
|
|
||||||
|
setCurrentFilter(filterSettings.currentFilter);
|
||||||
|
setCurrentSort(filterSettings.currentSort);
|
||||||
|
setCurrentPageSize(filterSettings.currentPageSize);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Set fitler values to local storage any time they are changed
|
||||||
|
useEffect(() => {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'rl-filter-settings',
|
||||||
|
JSON.stringify({
|
||||||
|
currentFilter,
|
||||||
|
currentSort,
|
||||||
|
currentPageSize,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [currentFilter, currentSort, currentPageSize]);
|
||||||
|
|
||||||
if (!data && !error) {
|
if (!data && !error) {
|
||||||
return <LoadingSpinner />;
|
return <LoadingSpinner />;
|
||||||
}
|
}
|
||||||
@@ -60,7 +85,7 @@ const RequestList: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle title={intl.formatMessage(messages.requests)} />
|
<PageTitle title={intl.formatMessage(messages.requests)} />
|
||||||
<div className="flex flex-col justify-between lg:items-end lg:flex-row">
|
<div className="flex flex-col justify-between mb-4 lg:items-end lg:flex-row">
|
||||||
<Header>{intl.formatMessage(messages.requests)}</Header>
|
<Header>{intl.formatMessage(messages.requests)}</Header>
|
||||||
<div className="flex flex-col flex-grow mt-2 sm:flex-row lg:flex-grow-0">
|
<div className="flex flex-col flex-grow mt-2 sm:flex-row lg:flex-grow-0">
|
||||||
<div className="flex flex-grow mb-2 sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
<div className="flex flex-grow mb-2 sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||||
@@ -82,8 +107,8 @@ const RequestList: React.FC = () => {
|
|||||||
id="filter"
|
id="filter"
|
||||||
name="filter"
|
name="filter"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setPageIndex(0);
|
|
||||||
setCurrentFilter(e.target.value as Filter);
|
setCurrentFilter(e.target.value as Filter);
|
||||||
|
router.push(router.pathname);
|
||||||
}}
|
}}
|
||||||
value={currentFilter}
|
value={currentFilter}
|
||||||
className="rounded-r-only"
|
className="rounded-r-only"
|
||||||
@@ -120,12 +145,8 @@ const RequestList: React.FC = () => {
|
|||||||
id="sort"
|
id="sort"
|
||||||
name="sort"
|
name="sort"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setPageIndex(0);
|
|
||||||
setCurrentSort(e.target.value as Sort);
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
setPageIndex(0);
|
|
||||||
setCurrentSort(e.target.value as Sort);
|
setCurrentSort(e.target.value as Sort);
|
||||||
|
router.push(router.pathname);
|
||||||
}}
|
}}
|
||||||
value={currentSort}
|
value={currentSort}
|
||||||
className="rounded-r-only"
|
className="rounded-r-only"
|
||||||
@@ -140,38 +161,25 @@ const RequestList: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<Table.TH>{intl.formatMessage(messages.mediaInfo)}</Table.TH>
|
|
||||||
<Table.TH>{intl.formatMessage(messages.status)}</Table.TH>
|
|
||||||
<Table.TH>{intl.formatMessage(messages.requestedAt)}</Table.TH>
|
|
||||||
<Table.TH>{intl.formatMessage(messages.modifiedBy)}</Table.TH>
|
|
||||||
<Table.TH></Table.TH>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<Table.TBody>
|
|
||||||
{data.results.map((request) => {
|
{data.results.map((request) => {
|
||||||
return (
|
return (
|
||||||
|
<div className="py-2" key={`request-list-${request.id}`}>
|
||||||
<RequestItem
|
<RequestItem
|
||||||
request={request}
|
request={request}
|
||||||
key={`request-list-${request.id}`}
|
|
||||||
revalidateList={() => revalidate()}
|
revalidateList={() => revalidate()}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{data.results.length === 0 && (
|
{data.results.length === 0 && (
|
||||||
<tr className="relative h-24 p-2 text-white">
|
<div className="flex flex-col items-center justify-center w-full py-24 text-white">
|
||||||
<Table.TD colSpan={6} noPadding>
|
<span className="text-2xl text-gray-400">
|
||||||
<div className="flex flex-col items-center justify-center w-screen p-6 lg:w-full">
|
|
||||||
<span className="text-base">
|
|
||||||
{intl.formatMessage(messages.noresults)}
|
{intl.formatMessage(messages.noresults)}
|
||||||
</span>
|
</span>
|
||||||
{currentFilter !== 'all' && (
|
{currentFilter !== 'all' && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Button
|
<Button
|
||||||
buttonSize="sm"
|
|
||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
onClick={() => setCurrentFilter('all')}
|
onClick={() => setCurrentFilter('all')}
|
||||||
>
|
>
|
||||||
@@ -180,13 +188,10 @@ const RequestList: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Table.TD>
|
|
||||||
</tr>
|
|
||||||
)}
|
)}
|
||||||
<tr className="bg-gray-700">
|
<div className="actions">
|
||||||
<Table.TD colSpan={6} noPadding>
|
|
||||||
<nav
|
<nav
|
||||||
className="flex flex-col items-center w-screen px-6 py-3 space-x-4 space-y-3 sm:space-y-0 sm:flex-row lg:w-full"
|
className="flex flex-col items-center mb-3 space-y-3 sm:space-y-0 sm:flex-row"
|
||||||
aria-label="Pagination"
|
aria-label="Pagination"
|
||||||
>
|
>
|
||||||
<div className="hidden lg:flex lg:flex-1">
|
<div className="hidden lg:flex lg:flex-1">
|
||||||
@@ -206,15 +211,17 @@ const RequestList: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center sm:flex-1 sm:justify-start lg:justify-center">
|
<div className="flex justify-center sm:flex-1 sm:justify-start lg:justify-center">
|
||||||
<span className="items-center -mt-3 text-sm sm:-ml-4 lg:ml-0 sm:mt-0">
|
<span className="items-center -mt-3 text-sm truncate sm:mt-0">
|
||||||
{intl.formatMessage(messages.resultsperpage, {
|
{intl.formatMessage(messages.resultsperpage, {
|
||||||
pageSize: (
|
pageSize: (
|
||||||
<select
|
<select
|
||||||
id="pageSize"
|
id="pageSize"
|
||||||
name="pageSize"
|
name="pageSize"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setPageIndex(0);
|
|
||||||
setCurrentPageSize(Number(e.target.value));
|
setCurrentPageSize(Number(e.target.value));
|
||||||
|
router
|
||||||
|
.push(router.pathname)
|
||||||
|
.then(() => window.scrollTo(0, 0));
|
||||||
}}
|
}}
|
||||||
value={currentPageSize}
|
value={currentPageSize}
|
||||||
className="inline short"
|
className="inline short"
|
||||||
@@ -232,22 +239,31 @@ const RequestList: React.FC = () => {
|
|||||||
<div className="flex justify-center flex-auto space-x-2 sm:justify-end sm:flex-1">
|
<div className="flex justify-center flex-auto space-x-2 sm:justify-end sm:flex-1">
|
||||||
<Button
|
<Button
|
||||||
disabled={!hasPrevPage}
|
disabled={!hasPrevPage}
|
||||||
onClick={() => setPageIndex((current) => current - 1)}
|
onClick={() =>
|
||||||
|
router
|
||||||
|
.push(`${router.pathname}?page=${page - 1}`, undefined, {
|
||||||
|
shallow: true,
|
||||||
|
})
|
||||||
|
.then(() => window.scrollTo(0, 0))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.previous)}
|
{intl.formatMessage(messages.previous)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={!hasNextPage}
|
disabled={!hasNextPage}
|
||||||
onClick={() => setPageIndex((current) => current + 1)}
|
onClick={() =>
|
||||||
|
router
|
||||||
|
.push(`${router.pathname}?page=${page + 1}`, undefined, {
|
||||||
|
shallow: true,
|
||||||
|
})
|
||||||
|
.then(() => window.scrollTo(0, 0))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.next)}
|
{intl.formatMessage(messages.next)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</Table.TD>
|
</div>
|
||||||
</tr>
|
|
||||||
</Table.TBody>
|
|
||||||
</Table>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,12 +18,11 @@ const messages = defineMessages({
|
|||||||
qualityprofile: 'Quality Profile',
|
qualityprofile: 'Quality Profile',
|
||||||
rootfolder: 'Root Folder',
|
rootfolder: 'Root Folder',
|
||||||
animenote: '* This series is an anime.',
|
animenote: '* This series is an anime.',
|
||||||
default: '(Default)',
|
default: '{name} (Default)',
|
||||||
loadingprofiles: 'Loading profiles…',
|
folder: '{path} ({space})',
|
||||||
loadingfolders: 'Loading folders…',
|
|
||||||
requestas: 'Request As',
|
requestas: 'Request As',
|
||||||
languageprofile: 'Language Profile',
|
languageprofile: 'Language Profile',
|
||||||
loadinglanguages: 'Loading languages…',
|
loading: 'Loading…',
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RequestOverrides = {
|
export type RequestOverrides = {
|
||||||
@@ -266,7 +265,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col items-center justify-between md:flex-row">
|
<div className="flex flex-col items-center justify-between md:flex-row">
|
||||||
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/4 md:pr-4 md:mb-0">
|
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/4 md:pr-4 md:mb-0">
|
||||||
<label htmlFor="server" className="text-label">
|
<label htmlFor="server">
|
||||||
{intl.formatMessage(messages.destinationserver)}
|
{intl.formatMessage(messages.destinationserver)}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -279,16 +278,17 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
|||||||
>
|
>
|
||||||
{data.map((server) => (
|
{data.map((server) => (
|
||||||
<option key={`server-list-${server.id}`} value={server.id}>
|
<option key={`server-list-${server.id}`} value={server.id}>
|
||||||
{server.name}
|
|
||||||
{server.isDefault && server.is4k === is4k
|
{server.isDefault && server.is4k === is4k
|
||||||
? ` ${intl.formatMessage(messages.default)}`
|
? intl.formatMessage(messages.default, {
|
||||||
: ''}
|
name: server.name,
|
||||||
|
})
|
||||||
|
: server.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/4 md:pr-4 md:mb-0">
|
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/4 md:pr-4 md:mb-0">
|
||||||
<label htmlFor="profile" className="text-label">
|
<label htmlFor="profile">
|
||||||
{intl.formatMessage(messages.qualityprofile)}
|
{intl.formatMessage(messages.qualityprofile)}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -298,10 +298,11 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
|||||||
onChange={(e) => setSelectedProfile(Number(e.target.value))}
|
onChange={(e) => setSelectedProfile(Number(e.target.value))}
|
||||||
onBlur={(e) => setSelectedProfile(Number(e.target.value))}
|
onBlur={(e) => setSelectedProfile(Number(e.target.value))}
|
||||||
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
|
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
|
||||||
|
disabled={isValidating || !serverData}
|
||||||
>
|
>
|
||||||
{isValidating && (
|
{(isValidating || !serverData) && (
|
||||||
<option value="">
|
<option value="">
|
||||||
{intl.formatMessage(messages.loadingprofiles)}
|
{intl.formatMessage(messages.loading)}
|
||||||
</option>
|
</option>
|
||||||
)}
|
)}
|
||||||
{!isValidating &&
|
{!isValidating &&
|
||||||
@@ -311,14 +312,17 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
|||||||
key={`profile-list${profile.id}`}
|
key={`profile-list${profile.id}`}
|
||||||
value={profile.id}
|
value={profile.id}
|
||||||
>
|
>
|
||||||
{profile.name}
|
|
||||||
{isAnime &&
|
{isAnime &&
|
||||||
serverData.server.activeAnimeProfileId === profile.id
|
serverData.server.activeAnimeProfileId === profile.id
|
||||||
? ` ${intl.formatMessage(messages.default)}`
|
? intl.formatMessage(messages.default, {
|
||||||
|
name: profile.name,
|
||||||
|
})
|
||||||
: !isAnime &&
|
: !isAnime &&
|
||||||
serverData.server.activeProfileId === profile.id
|
serverData.server.activeProfileId === profile.id
|
||||||
? ` ${intl.formatMessage(messages.default)}`
|
? intl.formatMessage(messages.default, {
|
||||||
: ''}
|
name: profile.name,
|
||||||
|
})
|
||||||
|
: profile.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -328,7 +332,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
|||||||
type === 'tv' ? 'md:pr-4' : ''
|
type === 'tv' ? 'md:pr-4' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<label htmlFor="folder" className="text-label">
|
<label htmlFor="folder">
|
||||||
{intl.formatMessage(messages.rootfolder)}
|
{intl.formatMessage(messages.rootfolder)}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -338,10 +342,11 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
|||||||
onChange={(e) => setSelectedFolder(e.target.value)}
|
onChange={(e) => setSelectedFolder(e.target.value)}
|
||||||
onBlur={(e) => setSelectedFolder(e.target.value)}
|
onBlur={(e) => setSelectedFolder(e.target.value)}
|
||||||
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
|
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
|
||||||
|
disabled={isValidating || !serverData}
|
||||||
>
|
>
|
||||||
{isValidating && (
|
{(isValidating || !serverData) && (
|
||||||
<option value="">
|
<option value="">
|
||||||
{intl.formatMessage(messages.loadingfolders)}
|
{intl.formatMessage(messages.loading)}
|
||||||
</option>
|
</option>
|
||||||
)}
|
)}
|
||||||
{!isValidating &&
|
{!isValidating &&
|
||||||
@@ -351,21 +356,33 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
|||||||
key={`folder-list${folder.id}`}
|
key={`folder-list${folder.id}`}
|
||||||
value={folder.path}
|
value={folder.path}
|
||||||
>
|
>
|
||||||
{folder.path} ({formatBytes(folder.freeSpace ?? 0)})
|
|
||||||
{isAnime &&
|
{isAnime &&
|
||||||
serverData.server.activeAnimeDirectory === folder.path
|
serverData.server.activeAnimeDirectory === folder.path
|
||||||
? ` ${intl.formatMessage(messages.default)}`
|
? intl.formatMessage(messages.default, {
|
||||||
|
name: intl.formatMessage(messages.folder, {
|
||||||
|
path: folder.path,
|
||||||
|
space: formatBytes(folder.freeSpace ?? 0),
|
||||||
|
}),
|
||||||
|
})
|
||||||
: !isAnime &&
|
: !isAnime &&
|
||||||
serverData.server.activeDirectory === folder.path
|
serverData.server.activeDirectory === folder.path
|
||||||
? ` ${intl.formatMessage(messages.default)}`
|
? intl.formatMessage(messages.default, {
|
||||||
: ''}
|
name: intl.formatMessage(messages.folder, {
|
||||||
|
path: folder.path,
|
||||||
|
space: formatBytes(folder.freeSpace ?? 0),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
: intl.formatMessage(messages.folder, {
|
||||||
|
path: folder.path,
|
||||||
|
space: formatBytes(folder.freeSpace ?? 0),
|
||||||
|
})}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{type === 'tv' && (
|
{type === 'tv' && (
|
||||||
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/4 md:mb-0">
|
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/4 md:mb-0">
|
||||||
<label htmlFor="language" className="text-label">
|
<label htmlFor="language">
|
||||||
{intl.formatMessage(messages.languageprofile)}
|
{intl.formatMessage(messages.languageprofile)}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -379,10 +396,11 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
|||||||
setSelectedLanguage(parseInt(e.target.value))
|
setSelectedLanguage(parseInt(e.target.value))
|
||||||
}
|
}
|
||||||
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
|
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
|
||||||
|
disabled={isValidating || !serverData}
|
||||||
>
|
>
|
||||||
{isValidating && (
|
{(isValidating || !serverData) && (
|
||||||
<option value="">
|
<option value="">
|
||||||
{intl.formatMessage(messages.loadinglanguages)}
|
{intl.formatMessage(messages.loading)}
|
||||||
</option>
|
</option>
|
||||||
)}
|
)}
|
||||||
{!isValidating &&
|
{!isValidating &&
|
||||||
@@ -392,16 +410,19 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
|||||||
key={`folder-list${language.id}`}
|
key={`folder-list${language.id}`}
|
||||||
value={language.id}
|
value={language.id}
|
||||||
>
|
>
|
||||||
{language.name}
|
|
||||||
{isAnime &&
|
{isAnime &&
|
||||||
serverData.server.activeAnimeLanguageProfileId ===
|
serverData.server.activeAnimeLanguageProfileId ===
|
||||||
language.id
|
language.id
|
||||||
? ` ${intl.formatMessage(messages.default)}`
|
? intl.formatMessage(messages.default, {
|
||||||
|
name: language.name,
|
||||||
|
})
|
||||||
: !isAnime &&
|
: !isAnime &&
|
||||||
serverData.server.activeLanguageProfileId ===
|
serverData.server.activeLanguageProfileId ===
|
||||||
language.id
|
language.id
|
||||||
? ` ${intl.formatMessage(messages.default)}`
|
? intl.formatMessage(messages.default, {
|
||||||
: ''}
|
name: language.name,
|
||||||
|
})
|
||||||
|
: language.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -412,7 +433,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
|||||||
)}
|
)}
|
||||||
{hasPermission([Permission.MANAGE_REQUESTS, Permission.MANAGE_USERS]) &&
|
{hasPermission([Permission.MANAGE_REQUESTS, Permission.MANAGE_USERS]) &&
|
||||||
selectedUser && (
|
selectedUser && (
|
||||||
<div className="mt-0 sm:mt-2">
|
<div className="first:mt-0 sm:mt-4">
|
||||||
<Listbox
|
<Listbox
|
||||||
as="div"
|
as="div"
|
||||||
value={selectedUser}
|
value={selectedUser}
|
||||||
@@ -421,7 +442,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
|||||||
>
|
>
|
||||||
{({ open }) => (
|
{({ open }) => (
|
||||||
<>
|
<>
|
||||||
<Listbox.Label className="text-label">
|
<Listbox.Label>
|
||||||
{intl.formatMessage(messages.requestas)}
|
{intl.formatMessage(messages.requestas)}
|
||||||
</Listbox.Label>
|
</Listbox.Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ const ResetPassword: React.FC = () => {
|
|||||||
{intl.formatMessage(messages.email)}
|
{intl.formatMessage(messages.email)}
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
<div className="form-input-field">
|
||||||
<Field
|
<Field
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ const ResetPassword: React.FC = () => {
|
|||||||
{intl.formatMessage(messages.password)}
|
{intl.formatMessage(messages.password)}
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
<div className="form-input-field">
|
||||||
<Field
|
<Field
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
@@ -141,7 +141,7 @@ const ResetPassword: React.FC = () => {
|
|||||||
{intl.formatMessage(messages.confirmpassword)}
|
{intl.formatMessage(messages.confirmpassword)}
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
<div className="form-input-field">
|
||||||
<Field
|
<Field
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
|
|||||||
@@ -1,71 +1,46 @@
|
|||||||
import React, { useContext } from 'react';
|
import React from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import {
|
import {
|
||||||
TvResult,
|
TvResult,
|
||||||
MovieResult,
|
MovieResult,
|
||||||
PersonResult,
|
PersonResult,
|
||||||
} from '../../../server/models/Search';
|
} from '../../../server/models/Search';
|
||||||
import { useSWRInfinite } from 'swr';
|
|
||||||
import ListView from '../Common/ListView';
|
import ListView from '../Common/ListView';
|
||||||
import { LanguageContext } from '../../context/LanguageContext';
|
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
import Header from '../Common/Header';
|
import Header from '../Common/Header';
|
||||||
import PageTitle from '../Common/PageTitle';
|
import PageTitle from '../Common/PageTitle';
|
||||||
import Error from '../../pages/_error';
|
import Error from '../../pages/_error';
|
||||||
|
import useDiscover from '../../hooks/useDiscover';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
search: 'Search',
|
search: 'Search',
|
||||||
searchresults: 'Search Results',
|
searchresults: 'Search Results',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface SearchResult {
|
|
||||||
page: number;
|
|
||||||
totalResults: number;
|
|
||||||
totalPages: number;
|
|
||||||
results: (MovieResult | TvResult | PersonResult)[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const Search: React.FC = () => {
|
const Search: React.FC = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { locale } = useContext(LanguageContext);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
|
||||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
|
||||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `/api/v1/search/?query=${router.query.query}&page=${
|
const {
|
||||||
pageIndex + 1
|
isLoadingInitialData,
|
||||||
}&language=${locale}`;
|
isEmpty,
|
||||||
},
|
isLoadingMore,
|
||||||
|
isReachingEnd,
|
||||||
|
titles,
|
||||||
|
fetchMore,
|
||||||
|
error,
|
||||||
|
} = useDiscover<MovieResult | TvResult | PersonResult>(
|
||||||
|
`/api/v1/search`,
|
||||||
{
|
{
|
||||||
initialSize: 3,
|
query: router.query.query,
|
||||||
}
|
},
|
||||||
|
{ hideAvailable: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
const isLoadingInitialData = !data && !error;
|
|
||||||
const isLoadingMore =
|
|
||||||
isLoadingInitialData ||
|
|
||||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
|
||||||
|
|
||||||
const fetchMore = () => {
|
|
||||||
setSize(size + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <Error statusCode={error.code} />;
|
return <Error statusCode={500} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const titles = data?.reduce(
|
|
||||||
(a, v) => [...a, ...v.results],
|
|
||||||
[] as (MovieResult | TvResult | PersonResult)[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const isEmpty = !isLoadingInitialData && titles?.length === 0;
|
|
||||||
const isReachingEnd =
|
|
||||||
isEmpty || (data && data[data.length - 1]?.results.length < 20);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle title={intl.formatMessage(messages.search)} />
|
<PageTitle title={intl.formatMessage(messages.search)} />
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const messages = defineMessages({
|
|||||||
save: 'Save Changes',
|
save: 'Save Changes',
|
||||||
saving: 'Saving…',
|
saving: 'Saving…',
|
||||||
agentenabled: 'Enable Agent',
|
agentenabled: 'Enable Agent',
|
||||||
|
botUsername: 'Bot Username',
|
||||||
|
botAvatarUrl: 'Bot Avatar URL',
|
||||||
webhookUrl: 'Webhook URL',
|
webhookUrl: 'Webhook URL',
|
||||||
webhookUrlPlaceholder: 'Server Settings → Integrations → Webhooks',
|
webhookUrlPlaceholder: 'Server Settings → Integrations → Webhooks',
|
||||||
discordsettingssaved: 'Discord notification settings saved successfully!',
|
discordsettingssaved: 'Discord notification settings saved successfully!',
|
||||||
@@ -20,7 +22,7 @@ const messages = defineMessages({
|
|||||||
testsent: 'Test notification sent!',
|
testsent: 'Test notification sent!',
|
||||||
test: 'Test',
|
test: 'Test',
|
||||||
notificationtypes: 'Notification Types',
|
notificationtypes: 'Notification Types',
|
||||||
validationWebhookUrl: 'You must provide a valid URL',
|
validationUrl: 'You must provide a valid URL',
|
||||||
});
|
});
|
||||||
|
|
||||||
const NotificationsDiscord: React.FC = () => {
|
const NotificationsDiscord: React.FC = () => {
|
||||||
@@ -31,9 +33,12 @@ const NotificationsDiscord: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const NotificationsDiscordSchema = Yup.object().shape({
|
const NotificationsDiscordSchema = Yup.object().shape({
|
||||||
|
botAvatarUrl: Yup.string()
|
||||||
|
.nullable()
|
||||||
|
.url(intl.formatMessage(messages.validationUrl)),
|
||||||
webhookUrl: Yup.string()
|
webhookUrl: Yup.string()
|
||||||
.required(intl.formatMessage(messages.validationWebhookUrl))
|
.required(intl.formatMessage(messages.validationUrl))
|
||||||
.url(intl.formatMessage(messages.validationWebhookUrl)),
|
.url(intl.formatMessage(messages.validationUrl)),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data && !error) {
|
if (!data && !error) {
|
||||||
@@ -45,6 +50,8 @@ const NotificationsDiscord: React.FC = () => {
|
|||||||
initialValues={{
|
initialValues={{
|
||||||
enabled: data.enabled,
|
enabled: data.enabled,
|
||||||
types: data.types,
|
types: data.types,
|
||||||
|
botUsername: data?.options.botUsername,
|
||||||
|
botAvatarUrl: data?.options.botAvatarUrl,
|
||||||
webhookUrl: data.options.webhookUrl,
|
webhookUrl: data.options.webhookUrl,
|
||||||
}}
|
}}
|
||||||
validationSchema={NotificationsDiscordSchema}
|
validationSchema={NotificationsDiscordSchema}
|
||||||
@@ -54,6 +61,8 @@ const NotificationsDiscord: React.FC = () => {
|
|||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
types: values.types,
|
types: values.types,
|
||||||
options: {
|
options: {
|
||||||
|
botUsername: values.botUsername,
|
||||||
|
botAvatarUrl: values.botAvatarUrl,
|
||||||
webhookUrl: values.webhookUrl,
|
webhookUrl: values.webhookUrl,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -77,6 +86,8 @@ const NotificationsDiscord: React.FC = () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
types: values.types,
|
types: values.types,
|
||||||
options: {
|
options: {
|
||||||
|
botUsername: values.botUsername,
|
||||||
|
botAvatarUrl: values.botAvatarUrl,
|
||||||
webhookUrl: values.webhookUrl,
|
webhookUrl: values.webhookUrl,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -97,12 +108,48 @@ const NotificationsDiscord: React.FC = () => {
|
|||||||
<Field type="checkbox" id="enabled" name="enabled" />
|
<Field type="checkbox" id="enabled" name="enabled" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="botUsername" className="text-label">
|
||||||
|
{intl.formatMessage(messages.botUsername)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field
|
||||||
|
id="botUsername"
|
||||||
|
name="botUsername"
|
||||||
|
type="text"
|
||||||
|
placeholder={intl.formatMessage(messages.botUsername)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.botUsername && touched.botUsername && (
|
||||||
|
<div className="error">{errors.botUsername}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="botAvatarUrl" className="text-label">
|
||||||
|
{intl.formatMessage(messages.botAvatarUrl)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field
|
||||||
|
id="botAvatarUrl"
|
||||||
|
name="botAvatarUrl"
|
||||||
|
type="text"
|
||||||
|
placeholder={intl.formatMessage(messages.botAvatarUrl)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.botAvatarUrl && touched.botAvatarUrl && (
|
||||||
|
<div className="error">{errors.botAvatarUrl}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="name" className="text-label">
|
<label htmlFor="name" className="text-label">
|
||||||
{intl.formatMessage(messages.webhookUrl)}
|
{intl.formatMessage(messages.webhookUrl)}
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input">
|
<div className="form-input">
|
||||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
<div className="form-input-field">
|
||||||
<Field
|
<Field
|
||||||
id="webhookUrl"
|
id="webhookUrl"
|
||||||
name="webhookUrl"
|
name="webhookUrl"
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import * as Yup from 'yup';
|
|||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import NotificationTypeSelector from '../../NotificationTypeSelector';
|
import NotificationTypeSelector from '../../NotificationTypeSelector';
|
||||||
import Alert from '../../Common/Alert';
|
import Alert from '../../Common/Alert';
|
||||||
|
import Badge from '../../Common/Badge';
|
||||||
|
import globalMessages from '../../../i18n/globalMessages';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
save: 'Save Changes',
|
save: 'Save Changes',
|
||||||
@@ -32,12 +34,34 @@ const messages = defineMessages({
|
|||||||
senderName: 'Sender Name',
|
senderName: 'Sender Name',
|
||||||
notificationtypes: 'Notification Types',
|
notificationtypes: 'Notification Types',
|
||||||
validationEmail: 'You must provide a valid email address',
|
validationEmail: 'You must provide a valid email address',
|
||||||
emailNotificationTypesAlert: 'Notification Email Recipients',
|
emailNotificationTypesAlert: 'Email Notification Recipients',
|
||||||
emailNotificationTypesAlertDescription:
|
emailNotificationTypesAlertDescription:
|
||||||
'For the "Media Requested" and "Media Failed" notification types,\
|
'<strong>Media Requested</strong>, <strong>Media Automatically Approved</strong>, and <strong>Media Failed</strong>\
|
||||||
notifications will only be sent to users with the "Manage Requests" permission.',
|
email notifications are sent to all users with the <strong>Manage Requests</strong> permission.',
|
||||||
|
emailNotificationTypesAlertDescriptionPt2:
|
||||||
|
'<strong>Media Approved</strong>, <strong>Media Declined</strong>, and <strong>Media Available</strong>\
|
||||||
|
email notifications are sent to the user who submitted the request.',
|
||||||
|
pgpPrivateKey: '<PgpLink>PGP</PgpLink> Private Key',
|
||||||
|
pgpPrivateKeyTip:
|
||||||
|
'Sign encrypted email messages (PGP password is also required)',
|
||||||
|
pgpPassword: '<PgpLink>PGP</PgpLink> Password',
|
||||||
|
pgpPasswordTip:
|
||||||
|
'Sign encrypted email messages (PGP private key is also required)',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export function PgpLink(msg: string): JSX.Element {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href="https://www.openpgp.org/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-gray-100 underline transition duration-300 hover:text-white"
|
||||||
|
>
|
||||||
|
{msg}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const NotificationsEmail: React.FC = () => {
|
const NotificationsEmail: React.FC = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { addToast } = useToasts();
|
const { addToast } = useToasts();
|
||||||
@@ -74,6 +98,8 @@ const NotificationsEmail: React.FC = () => {
|
|||||||
authPass: data.options.authPass,
|
authPass: data.options.authPass,
|
||||||
allowSelfSigned: data.options.allowSelfSigned,
|
allowSelfSigned: data.options.allowSelfSigned,
|
||||||
senderName: data.options.senderName,
|
senderName: data.options.senderName,
|
||||||
|
pgpPrivateKey: data.options.pgpPrivateKey,
|
||||||
|
pgpPassword: data.options.pgpPassword,
|
||||||
}}
|
}}
|
||||||
validationSchema={NotificationsEmailSchema}
|
validationSchema={NotificationsEmailSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
@@ -90,6 +116,8 @@ const NotificationsEmail: React.FC = () => {
|
|||||||
authPass: values.authPass,
|
authPass: values.authPass,
|
||||||
allowSelfSigned: values.allowSelfSigned,
|
allowSelfSigned: values.allowSelfSigned,
|
||||||
senderName: values.senderName,
|
senderName: values.senderName,
|
||||||
|
pgpPrivateKey: values.pgpPrivateKey,
|
||||||
|
pgpPassword: values.pgpPassword,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
addToast(intl.formatMessage(messages.emailsettingssaved), {
|
addToast(intl.formatMessage(messages.emailsettingssaved), {
|
||||||
@@ -119,6 +147,8 @@ const NotificationsEmail: React.FC = () => {
|
|||||||
authUser: values.authUser,
|
authUser: values.authUser,
|
||||||
authPass: values.authPass,
|
authPass: values.authPass,
|
||||||
senderName: values.senderName,
|
senderName: values.senderName,
|
||||||
|
pgpPrivateKey: values.pgpPrivateKey,
|
||||||
|
pgpPassword: values.pgpPassword,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,9 +164,34 @@ const NotificationsEmail: React.FC = () => {
|
|||||||
title={intl.formatMessage(messages.emailNotificationTypesAlert)}
|
title={intl.formatMessage(messages.emailNotificationTypesAlert)}
|
||||||
type="info"
|
type="info"
|
||||||
>
|
>
|
||||||
|
<p className="mb-2">
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
messages.emailNotificationTypesAlertDescription
|
messages.emailNotificationTypesAlertDescription,
|
||||||
|
{
|
||||||
|
strong: function strong(msg) {
|
||||||
|
return (
|
||||||
|
<strong className="font-normal text-indigo-100">
|
||||||
|
{msg}
|
||||||
|
</strong>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
)}
|
)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{intl.formatMessage(
|
||||||
|
messages.emailNotificationTypesAlertDescriptionPt2,
|
||||||
|
{
|
||||||
|
strong: function strong(msg) {
|
||||||
|
return (
|
||||||
|
<strong className="font-normal text-indigo-100">
|
||||||
|
{msg}
|
||||||
|
</strong>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</Alert>
|
</Alert>
|
||||||
<Form className="section">
|
<Form className="section">
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
@@ -152,7 +207,7 @@ const NotificationsEmail: React.FC = () => {
|
|||||||
{intl.formatMessage(messages.emailsender)}
|
{intl.formatMessage(messages.emailsender)}
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input">
|
<div className="form-input">
|
||||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
<div className="form-input-field">
|
||||||
<Field
|
<Field
|
||||||
id="emailFrom"
|
id="emailFrom"
|
||||||
name="emailFrom"
|
name="emailFrom"
|
||||||
@@ -170,7 +225,7 @@ const NotificationsEmail: React.FC = () => {
|
|||||||
{intl.formatMessage(messages.senderName)}
|
{intl.formatMessage(messages.senderName)}
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input">
|
<div className="form-input">
|
||||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
<div className="form-input-field">
|
||||||
<Field
|
<Field
|
||||||
id="senderName"
|
id="senderName"
|
||||||
name="senderName"
|
name="senderName"
|
||||||
@@ -185,7 +240,7 @@ const NotificationsEmail: React.FC = () => {
|
|||||||
{intl.formatMessage(messages.smtpHost)}
|
{intl.formatMessage(messages.smtpHost)}
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input">
|
<div className="form-input">
|
||||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
<div className="form-input-field">
|
||||||
<Field
|
<Field
|
||||||
id="smtpHost"
|
id="smtpHost"
|
||||||
name="smtpHost"
|
name="smtpHost"
|
||||||
@@ -203,7 +258,6 @@ const NotificationsEmail: React.FC = () => {
|
|||||||
{intl.formatMessage(messages.smtpPort)}
|
{intl.formatMessage(messages.smtpPort)}
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input">
|
<div className="form-input">
|
||||||
<div className="flex max-w-lg rounded-md shadow-sm sm:max-w-xs">
|
|
||||||
<Field
|
<Field
|
||||||
id="smtpPort"
|
id="smtpPort"
|
||||||
name="smtpPort"
|
name="smtpPort"
|
||||||
@@ -211,7 +265,6 @@ const NotificationsEmail: React.FC = () => {
|
|||||||
placeholder="465"
|
placeholder="465"
|
||||||
className="short"
|
className="short"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{errors.smtpPort && touched.smtpPort && (
|
{errors.smtpPort && touched.smtpPort && (
|
||||||
<div className="error">{errors.smtpPort}</div>
|
<div className="error">{errors.smtpPort}</div>
|
||||||
)}
|
)}
|
||||||
@@ -245,7 +298,7 @@ const NotificationsEmail: React.FC = () => {
|
|||||||
{intl.formatMessage(messages.authUser)}
|
{intl.formatMessage(messages.authUser)}
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input">
|
<div className="form-input">
|
||||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
<div className="form-input-field">
|
||||||
<Field id="authUser" name="authUser" type="text" />
|
<Field id="authUser" name="authUser" type="text" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -255,7 +308,7 @@ const NotificationsEmail: React.FC = () => {
|
|||||||
{intl.formatMessage(messages.authPass)}
|
{intl.formatMessage(messages.authPass)}
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input">
|
<div className="form-input">
|
||||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
<div className="form-input-field">
|
||||||
<Field
|
<Field
|
||||||
id="authPass"
|
id="authPass"
|
||||||
name="authPass"
|
name="authPass"
|
||||||
@@ -265,6 +318,56 @@ const NotificationsEmail: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="pgpPrivateKey" className="text-label">
|
||||||
|
<span className="mr-2">
|
||||||
|
{intl.formatMessage(messages.pgpPrivateKey, {
|
||||||
|
PgpLink: PgpLink,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<Badge badgeType="danger">
|
||||||
|
{intl.formatMessage(globalMessages.advanced)}
|
||||||
|
</Badge>
|
||||||
|
<span className="label-tip">
|
||||||
|
{intl.formatMessage(messages.pgpPrivateKeyTip)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field
|
||||||
|
id="pgpPrivateKey"
|
||||||
|
name="pgpPrivateKey"
|
||||||
|
as="textarea"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="pgpPassword" className="text-label">
|
||||||
|
<span className="mr-2">
|
||||||
|
{intl.formatMessage(messages.pgpPassword, {
|
||||||
|
PgpLink: PgpLink,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<Badge badgeType="danger">
|
||||||
|
{intl.formatMessage(globalMessages.advanced)}
|
||||||
|
</Badge>
|
||||||
|
<span className="label-tip">
|
||||||
|
{intl.formatMessage(messages.pgpPasswordTip)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field
|
||||||
|
id="pgpPassword"
|
||||||
|
name="pgpPassword"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
role="group"
|
role="group"
|
||||||
aria-labelledby="group-label"
|
aria-labelledby="group-label"
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ const NotificationsPushbullet: React.FC = () => {
|
|||||||
{intl.formatMessage(messages.accessToken)}
|
{intl.formatMessage(messages.accessToken)}
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input">
|
<div className="form-input">
|
||||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
<div className="form-input-field">
|
||||||
<Field
|
<Field
|
||||||
id="accessToken"
|
id="accessToken"
|
||||||
name="accessToken"
|
name="accessToken"
|
||||||
|
|||||||
@@ -41,13 +41,13 @@ const NotificationsPushover: React.FC = () => {
|
|||||||
accessToken: Yup.string()
|
accessToken: Yup.string()
|
||||||
.required(intl.formatMessage(messages.validationAccessTokenRequired))
|
.required(intl.formatMessage(messages.validationAccessTokenRequired))
|
||||||
.matches(
|
.matches(
|
||||||
/^a[A-Za-z0-9]{29}$/,
|
/^[a-z\d]{30}$/i,
|
||||||
intl.formatMessage(messages.validationAccessTokenRequired)
|
intl.formatMessage(messages.validationAccessTokenRequired)
|
||||||
),
|
),
|
||||||
userToken: Yup.string()
|
userToken: Yup.string()
|
||||||
.required(intl.formatMessage(messages.validationUserTokenRequired))
|
.required(intl.formatMessage(messages.validationUserTokenRequired))
|
||||||
.matches(
|
.matches(
|
||||||
/^[ug][A-Za-z0-9]{29}$/,
|
/^[a-z\d]{30}$/i,
|
||||||
intl.formatMessage(messages.validationUserTokenRequired)
|
intl.formatMessage(messages.validationUserTokenRequired)
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -153,7 +153,7 @@ const NotificationsPushover: React.FC = () => {
|
|||||||
{intl.formatMessage(messages.accessToken)}
|
{intl.formatMessage(messages.accessToken)}
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input">
|
<div className="form-input">
|
||||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
<div className="form-input-field">
|
||||||
<Field
|
<Field
|
||||||
id="accessToken"
|
id="accessToken"
|
||||||
name="accessToken"
|
name="accessToken"
|
||||||
@@ -171,7 +171,7 @@ const NotificationsPushover: React.FC = () => {
|
|||||||
{intl.formatMessage(messages.userToken)}
|
{intl.formatMessage(messages.userToken)}
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input">
|
<div className="form-input">
|
||||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
<div className="form-input-field">
|
||||||
<Field
|
<Field
|
||||||
id="userToken"
|
id="userToken"
|
||||||
name="userToken"
|
name="userToken"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user