Merge branch 'develop'
This commit is contained in:
@@ -245,6 +245,25 @@
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "hirenshah",
|
||||
"name": "hirenshah",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/418112?v=4",
|
||||
"profile": "https://github.com/hirenshah",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "TheCatLady",
|
||||
"name": "TheCatLady",
|
||||
"avatar_url": "https://avatars0.githubusercontent.com/u/52870424?v=4",
|
||||
"profile": "https://github.com/TheCatLady",
|
||||
"contributions": [
|
||||
"code",
|
||||
"translation"
|
||||
]
|
||||
}
|
||||
],
|
||||
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
||||
|
||||
34
.github/workflows/ci.yml
vendored
34
.github/workflows/ci.yml
vendored
@@ -10,7 +10,8 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-18.04
|
||||
name: Lint & Test Build
|
||||
runs-on: ubuntu-20.04
|
||||
container: node:12.18-alpine
|
||||
steps:
|
||||
- name: checkout
|
||||
@@ -24,10 +25,10 @@ jobs:
|
||||
- name: build
|
||||
run: yarn build
|
||||
build_and_push:
|
||||
name: Build and push Docker image to Docker Hub
|
||||
name: Build & Publish to Docker Hub
|
||||
needs: test
|
||||
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, 'skip ci')
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, '[skip ci]')
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
@@ -59,3 +60,28 @@ jobs:
|
||||
sctx/overseerr:${{ github.sha }}
|
||||
ghcr.io/sct/overseerr:develop
|
||||
ghcr.io/sct/overseerr:${{ github.sha }}
|
||||
discord:
|
||||
name: Send Discord Notification
|
||||
needs: build_and_push
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v1
|
||||
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
run: |
|
||||
failures=(neutral, skipped, timed_out, action_required)
|
||||
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||
echo ::set-output name=status::failure
|
||||
else
|
||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
||||
fi
|
||||
|
||||
- name: Post Status to Discord
|
||||
uses: sarisia/actions-status-discord@v1
|
||||
with:
|
||||
webhook: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
status: ${{ steps.status.outputs.status }}
|
||||
title: ${{ github.workflow }}
|
||||
nofail: true
|
||||
|
||||
2
.github/workflows/invalid_template.yml
vendored
2
.github/workflows/invalid_template.yml
vendored
@@ -6,7 +6,7 @@ on:
|
||||
|
||||
jobs:
|
||||
support:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: dessant/support-requests@v2
|
||||
with:
|
||||
|
||||
28
.github/workflows/release.yml
vendored
28
.github/workflows/release.yml
vendored
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-18.04
|
||||
runs-on: ubuntu-20.04
|
||||
container: node:12.18-alpine
|
||||
steps:
|
||||
- name: checkout
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
semantic-release:
|
||||
name: Tag and release latest version
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
@@ -41,3 +41,27 @@ jobs:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
run: npx semantic-release
|
||||
discord:
|
||||
name: Send Discord Notification
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v1
|
||||
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
run: |
|
||||
failures=(neutral, skipped, timed_out, action_required)
|
||||
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||
echo ::set-output name=status::failure
|
||||
else
|
||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
||||
fi
|
||||
|
||||
- name: Post Status to Discord
|
||||
uses: sarisia/actions-status-discord@v1
|
||||
with:
|
||||
webhook: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
status: ${{ steps.status.outputs.status }}
|
||||
title: ${{ github.workflow }}
|
||||
nofail: true
|
||||
|
||||
102
.github/workflows/snap.yaml
vendored
Normal file
102
.github/workflows/snap.yaml
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
name: Publish Snap
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
tags: [v*]
|
||||
pull_request: ~
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Lint & Test Build
|
||||
runs-on: ubuntu-20.04
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
container: node:12.18-alpine
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: install dependencies
|
||||
env:
|
||||
HUSKY_SKIP_INSTALL: 1
|
||||
run: yarn
|
||||
- name: lint
|
||||
run: yarn lint
|
||||
- name: build
|
||||
run: yarn build
|
||||
build-snap:
|
||||
name: Build Snap Package (${{ matrix.architecture }})
|
||||
needs: test
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
architecture:
|
||||
- amd64
|
||||
- arm64
|
||||
- armhf
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Prepare
|
||||
id: prepare
|
||||
run: |
|
||||
git fetch --prune --unshallow --tags
|
||||
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
||||
echo ::set-output name=RELEASE::stable
|
||||
else
|
||||
echo ::set-output name=RELEASE::edge
|
||||
fi
|
||||
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
||||
- name: Build Snap Package
|
||||
uses: diddlesnaps/snapcraft-multiarch-action@v1
|
||||
id: build
|
||||
with:
|
||||
architecture: ${{ matrix.architecture }}
|
||||
|
||||
- name: Upload Snap Package
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: overseerr-snap-package-${{ matrix.architecture }}
|
||||
path: ${{ steps.build.outputs.snap }}
|
||||
|
||||
- name: Review Snap Package
|
||||
uses: diddlesnaps/snapcraft-review-tools-action@v1
|
||||
with:
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
|
||||
- name: Publish Snap Package
|
||||
uses: snapcore/action-publish@v1
|
||||
with:
|
||||
store_login: ${{ secrets.SNAP_LOGIN }}
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
release: ${{ steps.prepare.outputs.RELEASE }}
|
||||
|
||||
discord:
|
||||
name: Send Discord Notification
|
||||
needs: build-snap
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v1
|
||||
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
run: |
|
||||
failures=(neutral, skipped, timed_out, action_required)
|
||||
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||
echo ::set-output name=status::failure
|
||||
else
|
||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
||||
fi
|
||||
|
||||
- name: Post Status to Discord
|
||||
uses: sarisia/actions-status-discord@v1
|
||||
with:
|
||||
webhook: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
status: ${{ steps.status.outputs.status }}
|
||||
title: ${{ github.workflow }}
|
||||
nofail: true
|
||||
2
.github/workflows/support.yml
vendored
2
.github/workflows/support.yml
vendored
@@ -6,7 +6,7 @@ on:
|
||||
|
||||
jobs:
|
||||
support:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: dessant/support-requests@v2
|
||||
with:
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -32,13 +32,16 @@ yarn-error.log*
|
||||
.vercel
|
||||
|
||||
# database
|
||||
config/db/db.sqlite3
|
||||
config/db/*.sqlite3
|
||||
config/settings.json
|
||||
|
||||
# logs
|
||||
config/logs/*.log*
|
||||
config/logs/*.json
|
||||
|
||||
# anidb mapping file
|
||||
config/anime-list.xml
|
||||
|
||||
# dist files
|
||||
dist
|
||||
|
||||
|
||||
66
README.md
66
README.md
@@ -16,7 +16,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>
|
||||
<img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr">
|
||||
<!-- 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-26-orange.svg"/></a>
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-28-orange.svg"/></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
</p>
|
||||
|
||||
@@ -36,14 +36,13 @@
|
||||
|
||||
- User profiles.
|
||||
- User settings page (to give users the ability to modify their Overseerr experience to their liking).
|
||||
- 4K requests (Includes multi-radarr/sonarr management for media)
|
||||
- Local user system (for those who don't use Plex).
|
||||
|
||||
## Planned Features
|
||||
|
||||
- More notification types.
|
||||
- Issues system. This will allow users to report issues with content on your media server.
|
||||
- Local user system (for those who don't use Plex).
|
||||
- Compatibility APIs (to work with existing tools in your system).
|
||||
- And a ton more! Check out our [issue tracker](https://github.com/sct/overseerr/issues) to see what features people have already requested.
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -71,7 +70,7 @@ After running Overseerr for the first time, configure it by visiting the web UI
|
||||
|
||||
## Preview
|
||||
|
||||
<img src="https://i.imgur.com/Mjbyruv.png">
|
||||
<img src="./public/preview.jpg">
|
||||
|
||||
## Support
|
||||
|
||||
@@ -105,41 +104,44 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
<!-- markdownlint-disable -->
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><a href="https://sct.dev"><img src="https://avatars1.githubusercontent.com/u/234213?v=4" width="100px;" alt=""/><br /><sub><b>sct</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=sct" title="Code">💻</a> <a href="#design-sct" title="Design">🎨</a> <a href="#ideas-sct" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center"><a href="https://github.com/azoitos"><img src="https://avatars2.githubusercontent.com/u/26529049?v=4" width="100px;" alt=""/><br /><sub><b>Alex Zoitos</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=azoitos" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/OwsleyJr"><img src="https://avatars3.githubusercontent.com/u/8635678?v=4" width="100px;" alt=""/><br /><sub><b>Brandon Cohen</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=OwsleyJr" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=OwsleyJr" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/Ahreluth"><img src="https://avatars2.githubusercontent.com/u/75682440?v=4" width="100px;" alt=""/><br /><sub><b>Ahreluth</b></sub></a><br /><a href="#translation-Ahreluth" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/KovalevArtem"><img src="https://avatars0.githubusercontent.com/u/36500228?v=4" width="100px;" alt=""/><br /><sub><b>KovalevArtem</b></sub></a><br /><a href="#translation-KovalevArtem" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/GiyomuWeb"><img src="https://avatars0.githubusercontent.com/u/62489209?v=4" width="100px;" alt=""/><br /><sub><b>GiyomuWeb</b></sub></a><br /><a href="#translation-GiyomuWeb" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/angrycuban13"><img src="https://avatars3.githubusercontent.com/u/39564898?v=4" width="100px;" alt=""/><br /><sub><b>Angry Cuban</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=angrycuban13" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://sct.dev"><img src="https://avatars1.githubusercontent.com/u/234213?v=4?s=100" width="100px;" alt=""/><br /><sub><b>sct</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=sct" title="Code">💻</a> <a href="#design-sct" title="Design">🎨</a> <a href="#ideas-sct" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center"><a href="https://github.com/azoitos"><img src="https://avatars2.githubusercontent.com/u/26529049?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alex Zoitos</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=azoitos" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/OwsleyJr"><img src="https://avatars3.githubusercontent.com/u/8635678?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Brandon Cohen</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=OwsleyJr" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=OwsleyJr" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/Ahreluth"><img src="https://avatars2.githubusercontent.com/u/75682440?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ahreluth</b></sub></a><br /><a href="#translation-Ahreluth" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/KovalevArtem"><img src="https://avatars0.githubusercontent.com/u/36500228?v=4?s=100" width="100px;" alt=""/><br /><sub><b>KovalevArtem</b></sub></a><br /><a href="#translation-KovalevArtem" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/GiyomuWeb"><img src="https://avatars0.githubusercontent.com/u/62489209?v=4?s=100" width="100px;" alt=""/><br /><sub><b>GiyomuWeb</b></sub></a><br /><a href="#translation-GiyomuWeb" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/angrycuban13"><img src="https://avatars3.githubusercontent.com/u/39564898?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Angry Cuban</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=angrycuban13" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/jvennik"><img src="https://avatars3.githubusercontent.com/u/6672637?v=4" width="100px;" alt=""/><br /><sub><b>jvennik</b></sub></a><br /><a href="#translation-jvennik" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/darknessgp"><img src="https://avatars0.githubusercontent.com/u/1521243?v=4" width="100px;" alt=""/><br /><sub><b>darknessgp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=darknessgp" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/saltydk"><img src="https://avatars1.githubusercontent.com/u/6587950?v=4" width="100px;" alt=""/><br /><sub><b>salty</b></sub></a><br /><a href="#infra-saltydk" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center"><a href="https://github.com/Shutruk"><img src="https://avatars2.githubusercontent.com/u/9198633?v=4" width="100px;" alt=""/><br /><sub><b>Shutruk</b></sub></a><br /><a href="#translation-Shutruk" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/krystiancharubin"><img src="https://avatars2.githubusercontent.com/u/17775600?v=4" width="100px;" alt=""/><br /><sub><b>Krystian Charubin</b></sub></a><br /><a href="#design-krystiancharubin" title="Design">🎨</a></td>
|
||||
<td align="center"><a href="https://github.com/kieron"><img src="https://avatars2.githubusercontent.com/u/8655212?v=4" width="100px;" alt=""/><br /><sub><b>Kieron Boswell</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=kieron" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/samwiseg0"><img src="https://avatars1.githubusercontent.com/u/2241731?v=4" width="100px;" alt=""/><br /><sub><b>samwiseg0</b></sub></a><br /><a href="#question-samwiseg0" title="Answering Questions">💬</a> <a href="#infra-samwiseg0" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center"><a href="https://github.com/jvennik"><img src="https://avatars3.githubusercontent.com/u/6672637?v=4?s=100" width="100px;" alt=""/><br /><sub><b>jvennik</b></sub></a><br /><a href="#translation-jvennik" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/darknessgp"><img src="https://avatars0.githubusercontent.com/u/1521243?v=4?s=100" width="100px;" alt=""/><br /><sub><b>darknessgp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=darknessgp" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/saltydk"><img src="https://avatars1.githubusercontent.com/u/6587950?v=4?s=100" width="100px;" alt=""/><br /><sub><b>salty</b></sub></a><br /><a href="#infra-saltydk" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center"><a href="https://github.com/Shutruk"><img src="https://avatars2.githubusercontent.com/u/9198633?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Shutruk</b></sub></a><br /><a href="#translation-Shutruk" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/krystiancharubin"><img src="https://avatars2.githubusercontent.com/u/17775600?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Krystian Charubin</b></sub></a><br /><a href="#design-krystiancharubin" title="Design">🎨</a></td>
|
||||
<td align="center"><a href="https://github.com/kieron"><img src="https://avatars2.githubusercontent.com/u/8655212?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Kieron Boswell</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=kieron" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/samwiseg0"><img src="https://avatars1.githubusercontent.com/u/2241731?v=4?s=100" width="100px;" alt=""/><br /><sub><b>samwiseg0</b></sub></a><br /><a href="#question-samwiseg0" title="Answering Questions">💬</a> <a href="#infra-samwiseg0" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/ecelebi29"><img src="https://avatars2.githubusercontent.com/u/8337120?v=4" width="100px;" alt=""/><br /><sub><b>ecelebi29</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/mmozeiko"><img src="https://avatars3.githubusercontent.com/u/1665010?v=4" width="100px;" alt=""/><br /><sub><b>Mārtiņš Možeiko</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=mmozeiko" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/mazzetta86"><img src="https://avatars2.githubusercontent.com/u/45591560?v=4" width="100px;" alt=""/><br /><sub><b>mazzetta86</b></sub></a><br /><a href="#translation-mazzetta86" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/Panzer1119"><img src="https://avatars1.githubusercontent.com/u/23016343?v=4" width="100px;" alt=""/><br /><sub><b>Paul Hagedorn</b></sub></a><br /><a href="#translation-Panzer1119" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/Shagon94"><img src="https://avatars3.githubusercontent.com/u/9140783?v=4" width="100px;" alt=""/><br /><sub><b>Shagon94</b></sub></a><br /><a href="#translation-Shagon94" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/sebstrgg"><img src="https://avatars3.githubusercontent.com/u/27026694?v=4" width="100px;" alt=""/><br /><sub><b>sebstrgg</b></sub></a><br /><a href="#translation-sebstrgg" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/danshilm"><img src="https://avatars2.githubusercontent.com/u/20923978?v=4" width="100px;" alt=""/><br /><sub><b>Danshil Mungur</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/ecelebi29"><img src="https://avatars2.githubusercontent.com/u/8337120?v=4?s=100" width="100px;" alt=""/><br /><sub><b>ecelebi29</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/mmozeiko"><img src="https://avatars3.githubusercontent.com/u/1665010?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Mārtiņš Možeiko</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=mmozeiko" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/mazzetta86"><img src="https://avatars2.githubusercontent.com/u/45591560?v=4?s=100" width="100px;" alt=""/><br /><sub><b>mazzetta86</b></sub></a><br /><a href="#translation-mazzetta86" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/Panzer1119"><img src="https://avatars1.githubusercontent.com/u/23016343?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Paul Hagedorn</b></sub></a><br /><a href="#translation-Panzer1119" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/Shagon94"><img src="https://avatars3.githubusercontent.com/u/9140783?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Shagon94</b></sub></a><br /><a href="#translation-Shagon94" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/sebstrgg"><img src="https://avatars3.githubusercontent.com/u/27026694?v=4?s=100" width="100px;" alt=""/><br /><sub><b>sebstrgg</b></sub></a><br /><a href="#translation-sebstrgg" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/danshilm"><img src="https://avatars2.githubusercontent.com/u/20923978?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Danshil Mungur</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/doob187"><img src="https://avatars1.githubusercontent.com/u/60312740?v=4" width="100px;" alt=""/><br /><sub><b>doob187</b></sub></a><br /><a href="#infra-doob187" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center"><a href="https://github.com/johnpyp"><img src="https://avatars2.githubusercontent.com/u/20625636?v=4" width="100px;" alt=""/><br /><sub><b>johnpyp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=johnpyp" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/ankarhem"><img src="https://avatars1.githubusercontent.com/u/14110063?v=4" width="100px;" alt=""/><br /><sub><b>Jakob Ankarhem</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Documentation">📖</a> <a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/jayesh100"><img src="https://avatars1.githubusercontent.com/u/8022175?v=4" width="100px;" alt=""/><br /><sub><b>Jayesh</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jayesh100" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/flying-sausages"><img src="https://avatars1.githubusercontent.com/u/23618693?v=4" width="100px;" alt=""/><br /><sub><b>flying-sausages</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=flying-sausages" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/doob187"><img src="https://avatars1.githubusercontent.com/u/60312740?v=4?s=100" width="100px;" alt=""/><br /><sub><b>doob187</b></sub></a><br /><a href="#infra-doob187" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center"><a href="https://github.com/johnpyp"><img src="https://avatars2.githubusercontent.com/u/20625636?v=4?s=100" width="100px;" alt=""/><br /><sub><b>johnpyp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=johnpyp" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/ankarhem"><img src="https://avatars1.githubusercontent.com/u/14110063?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jakob Ankarhem</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Documentation">📖</a> <a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/jayesh100"><img src="https://avatars1.githubusercontent.com/u/8022175?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jayesh</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jayesh100" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/flying-sausages"><img src="https://avatars1.githubusercontent.com/u/23618693?v=4?s=100" width="100px;" alt=""/><br /><sub><b>flying-sausages</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=flying-sausages" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/hirenshah"><img src="https://avatars2.githubusercontent.com/u/418112?v=4?s=100" width="100px;" alt=""/><br /><sub><b>hirenshah</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=hirenshah" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/TheCatLady"><img src="https://avatars0.githubusercontent.com/u/52870424?v=4?s=100" width="100px;" alt=""/><br /><sub><b>TheCatLady</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=TheCatLady" title="Code">💻</a> <a href="#translation-TheCatLady" title="Translation">🌍</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- markdownlint-enable -->
|
||||
<!-- markdownlint-restore -->
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
# Table of contents
|
||||
|
||||
* [Introduction](README.md)
|
||||
- [Introduction](README.md)
|
||||
|
||||
## Getting Started
|
||||
|
||||
* [Installation](getting-started/installation.md)
|
||||
- [Installation](getting-started/installation.md)
|
||||
|
||||
## Using Overseerr
|
||||
|
||||
- [Notifications](using-overseerr/notifications/README.md)
|
||||
- [Custom Webhooks](using-overseerr/notifications/webhooks.md)
|
||||
|
||||
## Support
|
||||
|
||||
* [Frequently Asked Questions](support/faq.md)
|
||||
* [Asking for Support](support/asking-for-support.md)
|
||||
- [Frequently Asked Questions](support/faq.md)
|
||||
- [Asking for Support](support/asking-for-support.md)
|
||||
|
||||
## Extending Overseerr
|
||||
|
||||
* [Reverse Proxy Examples](extending-overseerr/reverse-proxy-examples.md)
|
||||
|
||||
- [Reverse Proxy Examples](extending-overseerr/reverse-proxy-examples.md)
|
||||
|
||||
@@ -8,11 +8,13 @@ Base URLs cannot be configured in Overseerr. With this limitation, only subdomai
|
||||
|
||||
### Subdomain
|
||||
|
||||
Place in the `proxy-confs` folder as `overseerr.subdomain.conf`
|
||||
A sample is bundled in SWAG. This page is still the only source of truth, so the sample is not guaranteed to be up to date. If you catch an inconsistency, report it to the linuxserver team, or do a pull-request against the proxy-confs repository to update the sample.
|
||||
|
||||
Rename the sample file `overseerr.subdomain.conf.sample` to `overseerr.subdomain.conf` in the `proxy-confs`folder, or create `overseerr.subdomain.conf` in the same folder with the example below.
|
||||
|
||||
Example Configuration:
|
||||
|
||||
```text
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
@@ -112,8 +114,8 @@ server {
|
||||
add_header Referrer-Policy "no-referrer";
|
||||
# HTTP Strict Transport Security
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||
# Reduce XSS risks (Content-Security-Policy)
|
||||
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://plex.tv; style-src 'self' 'unsafe-inline' https://rsms.me/inter/inter.css; script-src 'self'; img-src 'self' data: https://plex.tv https://assets.plex.tv https://secure.gravatar.com https://i2.wp.com https://image.tmdb.org; font-src 'self' https://rsms.me/inter/font-files/" always;
|
||||
# Reduce XSS risks (Content-Security-Policy) - uncomment to use and add URLs whenever necessary
|
||||
# add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://plex.tv; style-src 'self' 'unsafe-inline' https://rsms.me/inter/inter.css; script-src 'self'; img-src 'self' data: https://plex.tv https://assets.plex.tv https://gravatar.com https://i2.wp.com https://image.tmdb.org; font-src 'self' https://rsms.me/inter/font-files/" always;
|
||||
# Prevent some categories of XSS attacks (X-XSS-Protection)
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
# Provide clickjacking protection (X-Frame-Options)
|
||||
|
||||
@@ -12,6 +12,7 @@ After running Overseerr for the first time, configure it by visiting the web UI
|
||||
|
||||
{% tabs %}
|
||||
{% tab title="Basic" %}
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-e LOG_LEVEL=info \
|
||||
@@ -21,9 +22,11 @@ docker run -d \
|
||||
--restart unless-stopped \
|
||||
sctx/overseerr
|
||||
```
|
||||
|
||||
{% endtab %}
|
||||
|
||||
{% tab title="UID/GID" %}
|
||||
|
||||
```text
|
||||
docker run -d \
|
||||
--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ] \
|
||||
@@ -34,9 +37,11 @@ docker run -d \
|
||||
--restart unless-stopped \
|
||||
sctx/overseerr
|
||||
```
|
||||
|
||||
{% endtab %}
|
||||
|
||||
{% tab title="Manual Update" %}
|
||||
|
||||
```text
|
||||
# Stop the Overseerr container
|
||||
docker stop overseerr
|
||||
@@ -50,6 +55,7 @@ docker pull sctx/overseerr
|
||||
# Run the Overseerr container with the same parameters as before
|
||||
docker run -d ...
|
||||
```
|
||||
|
||||
{% endtab %}
|
||||
{% endtabs %}
|
||||
|
||||
@@ -70,7 +76,7 @@ Use a 3rd party updating mechanism such as [Watchtower](https://github.com/conta
|
||||
Please refer to the [docker for windows documentation](https://docs.docker.com/docker-for-windows/) for installation.
|
||||
|
||||
{% hint style="danger" %}
|
||||
**WSL2 will need to be installed to prevent DB corruption! Please see** [**Docker Desktop WSL 2 backend**](https://docs.docker.com/docker-for-windows/wsl/) **on how to enable WSL2. The command below will only work with WSL2 installed! Details below.**
|
||||
**WSL2 will need to be installed to prevent DB corruption! Please see** [**Docker Desktop WSL 2 backend**](https://docs.docker.com/docker-for-windows/wsl/) **on how to enable WSL2. The command below will only work with WSL2 installed!**
|
||||
{% endhint %}
|
||||
|
||||
```bash
|
||||
@@ -81,116 +87,74 @@ docker run -d -e LOG_LEVEL=info -e TZ=Asia/Tokyo -p 5055:5055 -v "/your/path/her
|
||||
Docker on Windows works differently than it does on Linux; it uses a VM to run a stripped-down Linux and then runs docker within that. The volume mounts are exposed to the docker in this VM via SMB mounts. While this is fine for media, it is unacceptable for the `/app/config` directory because SMB does not support file locking. This will eventually corrupt your database which can lead to slow behavior and crashes. If you must run in docker on Windows, you should put the `/app/config` directory mount inside the VM and not on the Windows host. It's worth noting that this warning also extends to other containers which use SQLite databases.
|
||||
{% endhint %}
|
||||
|
||||
## Linux \(Unsupported\)
|
||||
## Linux
|
||||
|
||||
{% hint style="info" %}
|
||||
The [Overseerr snap](https://snapcraft.io/overseerr) is the only supported linux install method. Currently, the listening port cannot be changed. Port `5055` will need to be available on your host. To install snapd please refer to [Installing snapd](https://snapcraft.io/docs/installing-snapd).
|
||||
{% endhint %}
|
||||
|
||||
**To install:**
|
||||
|
||||
```
|
||||
sudo snap install overseerr
|
||||
```
|
||||
|
||||
**Updating:**
|
||||
Snap will keep Overseerr up-to-date automatically. You can force a refresh by using the following command.
|
||||
|
||||
```
|
||||
sudo snap refresh
|
||||
```
|
||||
|
||||
**To install the development build:**
|
||||
|
||||
```
|
||||
sudo snap install overseerr --edge
|
||||
```
|
||||
|
||||
{% hint style="danger" %}
|
||||
This version can break any moment. Be prepared to troubleshoot any issues that arise!
|
||||
{% endhint %}
|
||||
|
||||
## Third Party
|
||||
|
||||
{% tabs %}
|
||||
|
||||
{% tab title="Ubuntu 16.04+/Debian" %}
|
||||
{% hint style="danger" %}
|
||||
This install method is **not currently supported**. Docker is the only install method supported. Do not create issues or ask for support unless you are able to reproduce the issue with Docker.
|
||||
{% endhint %}
|
||||
|
||||
```bash
|
||||
# Install nodejs
|
||||
sudo apt-get install -y curl git gnupg2
|
||||
curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
# Install yarn
|
||||
curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
|
||||
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
|
||||
sudo apt-get update && sudo apt-get install yarn
|
||||
# Install Overseerr
|
||||
cd ~ && git clone https://github.com/sct/overseerr.git
|
||||
cd overseerr
|
||||
yarn install
|
||||
yarn build
|
||||
yarn start
|
||||
```
|
||||
|
||||
**Updating**
|
||||
|
||||
In order to update, you will need to re-build overseer.
|
||||
```bash
|
||||
cd ~/.overseerr
|
||||
git pull
|
||||
yarn install
|
||||
yarn build
|
||||
yarn start
|
||||
```
|
||||
{% endtab %}
|
||||
|
||||
{% tab title="Ubuntu ARM" %}
|
||||
{% hint style="danger" %}
|
||||
This install method is **not currently supported**. Docker is the only install method supported. Do not create issues or ask for support unless you are able to reproduce the issue with Docker.
|
||||
{% endhint %}
|
||||
|
||||
```bash
|
||||
# Install nodejs
|
||||
sudo apt-get install -y curl git gnupg2 build-essential
|
||||
curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
# Install yarn
|
||||
curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
|
||||
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
|
||||
sudo apt-get update && sudo apt-get install yarn
|
||||
# Install Overseerr
|
||||
cd ~ && git clone https://github.com/sct/overseerr.git
|
||||
cd overseerr
|
||||
npm config set python "$(which python3)"
|
||||
yarn install
|
||||
yarn build
|
||||
yarn start
|
||||
```
|
||||
|
||||
**Updating**
|
||||
|
||||
In order to update, you will need to re-build overseer.
|
||||
```bash
|
||||
cd ~/.overseerr
|
||||
git pull
|
||||
yarn install
|
||||
yarn build
|
||||
yarn start
|
||||
```
|
||||
{% endtab %}
|
||||
|
||||
{% tab title="ArchLinux \(3rd Party\)" %}
|
||||
Built from tag \(master\): [https://aur.archlinux.org/packages/overseerr/](https://aur.archlinux.org/packages/overseerr/)
|
||||
Built from latest \(develop\): [aur.archlinux.org/packages/overseerr-git](https://aur.archlinux.org/packages/overseerr-git/)
|
||||
**To install these just use your favorite AUR package manager:**
|
||||
|
||||
```bash
|
||||
yay -S overseer
|
||||
```
|
||||
{% endtab %}
|
||||
|
||||
{% tab title="Gentoo \(3rd Party\)" %}
|
||||
{% tab title="Gentoo" %}
|
||||
Portage overlay [GitHub Repository](https://github.com/chriscpritchard/overseerr-overlay)
|
||||
|
||||
Efforts will be made to keep up to date with the latest releases, however, this cannot be guaranteed.
|
||||
|
||||
To enable using eselect repository, run:
|
||||
|
||||
```bash
|
||||
eselect repository add overseerr-overlay git https://github.com/chriscpritchard/overseerr-overlay.git
|
||||
```
|
||||
|
||||
Once complete, you can just run:
|
||||
|
||||
```bash
|
||||
emerge www-apps/overseerr
|
||||
```
|
||||
|
||||
{% endtab %}
|
||||
|
||||
{% endtabs %}
|
||||
|
||||
## Swizzin \(Third party\)
|
||||
{% tab title="Swizzin" %}
|
||||
The installation is not implemented via docker, but barebones. The latest released version of overseerr will be used.
|
||||
Please see the [swizzin documentation](https://swizzin.ltd/applications/overseerr) for more information.
|
||||
|
||||
To install, run the following:
|
||||
|
||||
```bash
|
||||
box install overseerr
|
||||
```
|
||||
|
||||
To upgrade, run the following:
|
||||
|
||||
```bash
|
||||
box upgrade overseerr
|
||||
```
|
||||
|
||||
{% endtab %}
|
||||
|
||||
{% endtabs %}
|
||||
|
||||
28
docs/using-overseerr/notifications/README.md
Normal file
28
docs/using-overseerr/notifications/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Notifications
|
||||
|
||||
Overseerr already supports a good number of notification agents, such as **Discord**, **Slack** and **Pushover**. New agents are always considered for development, if there is enough demand for it.
|
||||
|
||||
## Currently Supported Notification Agents
|
||||
|
||||
- Email
|
||||
- Discord
|
||||
- Slack
|
||||
- Telegram
|
||||
- Pushover
|
||||
- [Webhooks](./webhooks.md)
|
||||
|
||||
## Setting up Notifications
|
||||
|
||||
Configuring your notifications is _very simple_. First, you will need to visit the **Settings** page and click **Notifications** in the menu. This will present you with all of the currently available notification agents. Click on each one individually to configure them.
|
||||
|
||||
You must configure which type of notifications you want to send _per agent_. If no types are selected, you will not receive any notifications!
|
||||
|
||||
Some agents may have specific configuration gotchas that will be covered in each notification agents documentation page.
|
||||
|
||||
{% hint style="danger" %}
|
||||
Currently, you will **not receive notifications** for any auto-approved requests. However, you will still receive a notification when the media becomes available.
|
||||
{% endhint %}
|
||||
|
||||
## Requesting new agents
|
||||
|
||||
If we do not currently support a notification agent you would like, feel free to request it on our [GitHub Issues](https://github.com/sct/overseerr/issues). Make sure to search first to see if someone else already requested it!
|
||||
56
docs/using-overseerr/notifications/webhooks.md
Normal file
56
docs/using-overseerr/notifications/webhooks.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Webhooks
|
||||
|
||||
Webhooks let you post a custom JSON payload to any endpoint you like. You can also set an authorization header for security purposes.
|
||||
|
||||
## Configuration
|
||||
|
||||
The following configuration options are available:
|
||||
|
||||
### Webhook URL (Required)
|
||||
|
||||
The URL you would like to post notifications to. Your JSON will be sent as the body of the request.
|
||||
|
||||
### Authorization Header
|
||||
|
||||
Custom authorization header. Anything entered for this will be sent as an `Authorization` header.
|
||||
|
||||
### Custom JSON Payload (Required)
|
||||
|
||||
Design your JSON payload as you see fit. JSON is validated before you can save or test. Overseerr provides several [template variables](./webhooks.md#template-variables) for use in the payload which will be replaced with actual values when the notifications are sent.
|
||||
|
||||
You can always reset back to the default custom payload setting by clicking the `Reset to Default JSON Payload` button under the editor.
|
||||
|
||||
## Template Variables
|
||||
|
||||
### Main
|
||||
|
||||
- `{{notification_type}}` The type of notification. (Ex. `MEDIA_PENDING` or `MEDIA_APPROVED`)
|
||||
- `{{subject}}` The notification subject message. (For request notifications, this is the media title)
|
||||
- `{{message}}` Notification message body. (For request notifications, this is the media's overview/synopsis)
|
||||
- `{{image}}` Associated image with the request. (For request notifications, this is the media's poster)
|
||||
|
||||
### Notify User
|
||||
|
||||
These variables are usually the target user of the notification.
|
||||
|
||||
- `{{notifyuser_username}}` Target user's username.
|
||||
- `{{notifyuser_email}}` Target user's email.
|
||||
- `{{notifyuser_avatar}}` Target user's avatar.
|
||||
|
||||
### Media
|
||||
|
||||
These variables are only included in media related notifications, such as requests.
|
||||
|
||||
- `{{media_type}}` Media type. Either `movie` or `tv`.
|
||||
- `{{media_tmdbid}}` Media's TMDB ID.
|
||||
- `{{media_imdbid}}` Media's IMDB ID.
|
||||
- `{{media_tvdbid}}` Media's TVDB ID.
|
||||
- `{{media_status}}` Media's availability status. (Ex. `AVAILABLE` or `PENDING`)
|
||||
- `{{media_status4k}}` Media's 4K availability status. (Ex. `AVAILABLE` or `PENDING`)
|
||||
|
||||
### Special Key Variables
|
||||
|
||||
These variables must be used as a key in the JSON Payload. (Ex, `"{{extra}}": []`).
|
||||
|
||||
- `{{extra}}` This will override the value of the property to be the pre-formatted "extra" array that can come along with certain notifications. Using this variable is _not required_.
|
||||
- `{{media}}` This will override the value of the property to `null` if there is no media object passed along with the notification.
|
||||
@@ -1,6 +1,8 @@
|
||||
const devConfig = {
|
||||
type: 'sqlite',
|
||||
database: 'config/db/db.sqlite3',
|
||||
database: process.env.CONFIG_DIRECTORY
|
||||
? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3`
|
||||
: 'config/db/db.sqlite3',
|
||||
synchronize: true,
|
||||
migrationsRun: false,
|
||||
logging: false,
|
||||
@@ -15,7 +17,9 @@ const devConfig = {
|
||||
|
||||
const prodConfig = {
|
||||
type: 'sqlite',
|
||||
database: 'config/db/db.sqlite3',
|
||||
database: process.env.CONFIG_DIRECTORY
|
||||
? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3`
|
||||
: 'config/db/db.sqlite3',
|
||||
synchronize: false,
|
||||
logging: false,
|
||||
entities: ['dist/entity/**/*.js'],
|
||||
|
||||
@@ -20,11 +20,16 @@ components:
|
||||
plexToken:
|
||||
type: string
|
||||
readOnly: true
|
||||
userType:
|
||||
type: integer
|
||||
example: 1
|
||||
readOnly: true
|
||||
permissions:
|
||||
type: number
|
||||
example: 0
|
||||
avatar:
|
||||
type: string
|
||||
readOnly: true
|
||||
createdAt:
|
||||
type: string
|
||||
example: '2020-09-02T05:02:23.000Z'
|
||||
@@ -45,7 +50,6 @@ components:
|
||||
required:
|
||||
- id
|
||||
- email
|
||||
- permissions
|
||||
- createdAt
|
||||
- updatedAt
|
||||
MainSettings:
|
||||
@@ -701,6 +705,15 @@ components:
|
||||
- $ref: '#/components/schemas/User'
|
||||
- type: string
|
||||
nullable: true
|
||||
is4k:
|
||||
type: boolean
|
||||
example: false
|
||||
serverId:
|
||||
type: number
|
||||
profileId:
|
||||
type: number
|
||||
rootFolder:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- status
|
||||
@@ -855,6 +868,22 @@ components:
|
||||
properties:
|
||||
webhookUrl:
|
||||
type: string
|
||||
WebhookSettings:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
example: false
|
||||
types:
|
||||
type: number
|
||||
example: 2
|
||||
options:
|
||||
type: object
|
||||
properties:
|
||||
webhookUrl:
|
||||
type: string
|
||||
jsonPayload:
|
||||
type: string
|
||||
TelegramSettings:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1832,6 +1861,52 @@ paths:
|
||||
responses:
|
||||
'204':
|
||||
description: Test notification attempted
|
||||
/settings/notifications/webhook:
|
||||
get:
|
||||
summary: Return current webhook notification settings
|
||||
description: Returns current webhook notification settings in JSON format
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
'200':
|
||||
description: Returned webhook settings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WebhookSettings'
|
||||
post:
|
||||
summary: Update webhook notification settings
|
||||
description: Update current webhook notification settings with provided values
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WebhookSettings'
|
||||
responses:
|
||||
'200':
|
||||
description: 'Values were sucessfully updated'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WebhookSettings'
|
||||
/settings/notifications/webhook/test:
|
||||
post:
|
||||
summary: Test the provided slack settings
|
||||
description: Sends a test notification to the slack agent
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SlackSettings'
|
||||
responses:
|
||||
'204':
|
||||
description: Test notification attempted
|
||||
/settings/about:
|
||||
get:
|
||||
summary: Return current about stats
|
||||
@@ -1898,6 +1973,34 @@ paths:
|
||||
type: string
|
||||
required:
|
||||
- authToken
|
||||
/auth/local:
|
||||
post:
|
||||
summary: Login using a local account
|
||||
description: Takes an `email` and a `password` to log the user in. Generates a session cookie for use in further requests.
|
||||
security: []
|
||||
tags:
|
||||
- auth
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/User'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
required:
|
||||
- email
|
||||
- password
|
||||
/auth/logout:
|
||||
get:
|
||||
summary: Logout and clear session cookie
|
||||
@@ -2364,6 +2467,15 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: number
|
||||
is4k:
|
||||
type: boolean
|
||||
example: false
|
||||
serverId:
|
||||
type: number
|
||||
profileId:
|
||||
type: number
|
||||
rootFolder:
|
||||
type: string
|
||||
required:
|
||||
- mediaType
|
||||
- mediaId
|
||||
@@ -2374,6 +2486,30 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MediaRequest'
|
||||
/request/count:
|
||||
get:
|
||||
summary: Returns request counts
|
||||
description: |
|
||||
Returns the number of pending and approved requests.
|
||||
tags:
|
||||
- request
|
||||
responses:
|
||||
'200':
|
||||
description: Request counts returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
pending:
|
||||
type: number
|
||||
example: 0
|
||||
approved:
|
||||
type: number
|
||||
example: 10
|
||||
required:
|
||||
- pending
|
||||
- approved
|
||||
/request/{requestId}:
|
||||
get:
|
||||
summary: Requests a specific MediaRequest
|
||||
@@ -2395,6 +2531,26 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MediaRequest'
|
||||
put:
|
||||
summary: Update a specific MediaRequest
|
||||
description: Updats a specific media request and returns the request in JSON format. Requires the `MANAGE_REQUESTS` permission.
|
||||
tags:
|
||||
- request
|
||||
parameters:
|
||||
- in: path
|
||||
name: requestId
|
||||
description: Request ID
|
||||
required: true
|
||||
example: 1
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Succesfully updated request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MediaRequest'
|
||||
delete:
|
||||
summary: Delete a request
|
||||
description: Removes a request. If the user has the `MANAGE_REQUESTS` permission, then any request can be removed. Otherwise, only pending requests can be removed.
|
||||
@@ -2896,7 +3052,7 @@ paths:
|
||||
name: sort
|
||||
schema:
|
||||
type: string
|
||||
enum: [added, modified]
|
||||
enum: [added, modified, mediaAdded]
|
||||
default: added
|
||||
responses:
|
||||
'200':
|
||||
@@ -2954,6 +3110,86 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Collection'
|
||||
/service/radarr:
|
||||
get:
|
||||
summary: Returns non-sensitive radarr server list
|
||||
description: Returns a list of radarr servers, both ID and name in JSON format
|
||||
tags:
|
||||
- service
|
||||
responses:
|
||||
'200':
|
||||
description: Request successful
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RadarrSettings'
|
||||
/service/radarr/{radarrId}:
|
||||
get:
|
||||
summary: Returns radarr server quality profiles and root folders
|
||||
description: Returns a radarr server quality profile and root folder details in JSON format
|
||||
tags:
|
||||
- service
|
||||
parameters:
|
||||
- in: path
|
||||
name: radarrId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
example: 0
|
||||
responses:
|
||||
'200':
|
||||
description: Request successful
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
server:
|
||||
$ref: '#/components/schemas/RadarrSettings'
|
||||
profiles:
|
||||
$ref: '#/components/schemas/ServiceProfile'
|
||||
/service/sonarr:
|
||||
get:
|
||||
summary: Returns non-sensitive sonarr server list
|
||||
description: Returns a list of sonarr servers, both ID and name in JSON format
|
||||
tags:
|
||||
- service
|
||||
responses:
|
||||
'200':
|
||||
description: Request successful
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SonarrSettings'
|
||||
/service/sonarr/{sonarrId}:
|
||||
get:
|
||||
summary: Returns sonarr server quality profiles and root folders
|
||||
description: Returns a sonarr server quality profile and root folder details in JSON format
|
||||
tags:
|
||||
- service
|
||||
parameters:
|
||||
- in: path
|
||||
name: sonarrId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
example: 0
|
||||
responses:
|
||||
'200':
|
||||
description: Request successful
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
server:
|
||||
$ref: '#/components/schemas/SonarrSettings'
|
||||
profiles:
|
||||
$ref: '#/components/schemas/ServiceProfile'
|
||||
|
||||
security:
|
||||
- cookieAuth: []
|
||||
|
||||
45
package.json
45
package.json
@@ -18,39 +18,44 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svgr/webpack": "^5.5.0",
|
||||
"ace-builds": "^1.4.12",
|
||||
"axios": "^0.21.1",
|
||||
"bcrypt": "^5.0.0",
|
||||
"body-parser": "^1.19.0",
|
||||
"bowser": "^2.11.0",
|
||||
"connect-typeorm": "^1.1.4",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"email-templates": "^8.0.2",
|
||||
"email-templates": "^8.0.3",
|
||||
"express": "^4.17.1",
|
||||
"express-openapi-validator": "^4.10.2",
|
||||
"express-openapi-validator": "^4.10.8",
|
||||
"express-session": "^1.17.1",
|
||||
"formik": "^2.2.6",
|
||||
"gravatar-url": "^3.1.0",
|
||||
"intl": "^1.2.5",
|
||||
"lodash": "^4.17.20",
|
||||
"next": "^10.0.4",
|
||||
"next": "10.0.3",
|
||||
"node-schedule": "^1.3.2",
|
||||
"nodemailer": "^6.4.17",
|
||||
"nookies": "^2.5.0",
|
||||
"nookies": "^2.5.1",
|
||||
"plex-api": "^5.3.1",
|
||||
"pug": "^3.0.0",
|
||||
"react": "17.0.1",
|
||||
"react-ace": "^9.2.1",
|
||||
"react-dom": "17.0.1",
|
||||
"react-intersection-observer": "^8.31.0",
|
||||
"react-intl": "^5.10.11",
|
||||
"react-intl": "^5.10.16",
|
||||
"react-markdown": "^5.0.3",
|
||||
"react-spring": "^8.0.27",
|
||||
"react-toast-notifications": "^2.4.0",
|
||||
"react-transition-group": "^4.4.1",
|
||||
"react-truncate-markup": "^5.0.1",
|
||||
"react-truncate-markup": "^5.1.0",
|
||||
"react-use-clipboard": "1.0.7",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"secure-random-password": "^0.2.2",
|
||||
"sqlite3": "^5.0.0",
|
||||
"swagger-ui-express": "^4.1.6",
|
||||
"swr": "^0.3.11",
|
||||
"typeorm": "^0.2.29",
|
||||
"swr": "^0.4.0",
|
||||
"typeorm": "^0.2.30",
|
||||
"uuid": "^8.3.2",
|
||||
"winston": "^3.3.3",
|
||||
"winston-daily-rotate-file": "^4.5.0",
|
||||
@@ -68,48 +73,50 @@
|
||||
"@semantic-release/git": "^9.0.0",
|
||||
"@tailwindcss/aspect-ratio": "^0.2.0",
|
||||
"@tailwindcss/forms": "^0.2.1",
|
||||
"@tailwindcss/typography": "^0.3.1",
|
||||
"@tailwindcss/typography": "^0.4.0",
|
||||
"@types/bcrypt": "^3.0.0",
|
||||
"@types/body-parser": "^1.19.0",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
"@types/email-templates": "^8.0.0",
|
||||
"@types/express": "^4.17.9",
|
||||
"@types/express": "^4.17.11",
|
||||
"@types/express-session": "^1.17.0",
|
||||
"@types/lodash": "^4.14.167",
|
||||
"@types/node": "^14.14.20",
|
||||
"@types/node": "^14.14.21",
|
||||
"@types/node-schedule": "^1.3.1",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"@types/react-toast-notifications": "^2.4.0",
|
||||
"@types/react-transition-group": "^4.4.0",
|
||||
"@types/secure-random-password": "^0.2.0",
|
||||
"@types/swagger-ui-express": "^4.1.2",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"@types/xml2js": "^0.4.7",
|
||||
"@types/yamljs": "^0.2.31",
|
||||
"@types/yup": "^0.29.11",
|
||||
"@typescript-eslint/eslint-plugin": "^4.12.0",
|
||||
"@typescript-eslint/parser": "^4.12.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.13.0",
|
||||
"@typescript-eslint/parser": "^4.13.0",
|
||||
"autoprefixer": "^9",
|
||||
"babel-plugin-react-intl": "^8.2.25",
|
||||
"babel-plugin-react-intl-auto": "^3.3.0",
|
||||
"commitizen": "^4.2.2",
|
||||
"commitizen": "^4.2.3",
|
||||
"copyfiles": "^2.4.1",
|
||||
"cz-conventional-changelog": "^3.3.0",
|
||||
"eslint": "^7.17.0",
|
||||
"eslint": "^7.18.0",
|
||||
"eslint-config-prettier": "^7.1.0",
|
||||
"eslint-plugin-formatjs": "^2.10.2",
|
||||
"eslint-plugin-formatjs": "^2.10.3",
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"eslint-plugin-prettier": "^3.3.1",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"extract-react-intl-messages": "^4.1.1",
|
||||
"husky": "^4.3.6",
|
||||
"husky": "^4.3.8",
|
||||
"lint-staged": "^10.5.3",
|
||||
"nodemon": "^2.0.6",
|
||||
"nodemon": "^2.0.7",
|
||||
"postcss": "^7",
|
||||
"postcss-preset-env": "^6.7.0",
|
||||
"prettier": "^2.2.1",
|
||||
"semantic-release": "^17.3.1",
|
||||
"semantic-release": "^17.3.3",
|
||||
"semantic-release-docker": "^2.2.0",
|
||||
"tailwindcss": "npm:@tailwindcss/postcss7-compat",
|
||||
"ts-node": "^9.1.1",
|
||||
|
||||
BIN
public/preview.jpg
Normal file
BIN
public/preview.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.4 MiB |
223
server/api/animelist.ts
Normal file
223
server/api/animelist.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import axios from 'axios';
|
||||
import xml2js from 'xml2js';
|
||||
import fs, { promises as fsp } from 'fs';
|
||||
import path from 'path';
|
||||
import logger from '../logger';
|
||||
|
||||
const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds
|
||||
// originally at https://raw.githubusercontent.com/ScudLee/anime-lists/master/anime-list.xml
|
||||
const MAPPING_URL =
|
||||
'https://raw.githubusercontent.com/Anime-Lists/anime-lists/master/anime-list.xml';
|
||||
const LOCAL_PATH = path.join(__dirname, '../../config/anime-list.xml');
|
||||
|
||||
const mappingRegexp = new RegExp(/;[0-9]+-([0-9]+)/g);
|
||||
|
||||
// Anime-List xml files are community maintained mappings that Hama agent uses to map AniDB IDs to tvdb/tmdb IDs
|
||||
// https://github.com/Anime-Lists/anime-lists/
|
||||
|
||||
interface AnimeMapping {
|
||||
$: {
|
||||
anidbseason: string;
|
||||
tvdbseason: string;
|
||||
};
|
||||
_: string;
|
||||
}
|
||||
|
||||
interface Anime {
|
||||
$: {
|
||||
anidbid: number;
|
||||
tvdbid?: string;
|
||||
defaulttvdbseason?: string;
|
||||
tmdbid?: number;
|
||||
imdbid?: string;
|
||||
};
|
||||
'mapping-list'?: {
|
||||
mapping: AnimeMapping[];
|
||||
}[];
|
||||
}
|
||||
|
||||
interface AnimeList {
|
||||
'anime-list': {
|
||||
anime: Anime[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnidbItem {
|
||||
tvdbId?: number;
|
||||
tmdbId?: number;
|
||||
imdbId?: string;
|
||||
}
|
||||
|
||||
class AnimeListMapping {
|
||||
private syncing = false;
|
||||
|
||||
private mapping: { [anidbId: number]: AnidbItem } = {};
|
||||
|
||||
// mapping file modification date when it was loaded
|
||||
private mappingModified: Date | null = null;
|
||||
|
||||
// each episode in season 0 from TVDB can map to movie
|
||||
private specials: { [tvdbId: number]: { [episode: number]: AnidbItem } } = {};
|
||||
|
||||
public isLoaded = () => Object.keys(this.mapping).length !== 0;
|
||||
|
||||
private loadFromFile = async () => {
|
||||
logger.info('Loading mapping file', { label: 'Anime-List Sync' });
|
||||
try {
|
||||
const mappingStat = await fsp.stat(LOCAL_PATH);
|
||||
const file = await fsp.readFile(LOCAL_PATH);
|
||||
const xml = (await xml2js.parseStringPromise(file)) as AnimeList;
|
||||
|
||||
this.mapping = {};
|
||||
this.specials = {};
|
||||
for (const anime of xml['anime-list'].anime) {
|
||||
// tvdbId can be nonnumber, like 'movie' string
|
||||
let tvdbId: number | undefined;
|
||||
if (anime.$.tvdbid && !isNaN(Number(anime.$.tvdbid))) {
|
||||
tvdbId = Number(anime.$.tvdbid);
|
||||
} else {
|
||||
tvdbId = undefined;
|
||||
}
|
||||
|
||||
let imdbIds: (string | undefined)[];
|
||||
if (anime.$.imdbid) {
|
||||
// if there are multiple imdb entries, then they map to different movies
|
||||
imdbIds = anime.$.imdbid.split(',');
|
||||
} else {
|
||||
// in case there is no imdbid, that's ok as there will be tmdbid
|
||||
imdbIds = [undefined];
|
||||
}
|
||||
|
||||
const tmdbId = anime.$.tmdbid ? Number(anime.$.tmdbid) : undefined;
|
||||
const anidbId = Number(anime.$.anidbid);
|
||||
this.mapping[anidbId] = {
|
||||
// for season 0 ignore tvdbid, because this must be movie/OVA
|
||||
tvdbId: anime.$.defaulttvdbseason === '0' ? undefined : tvdbId,
|
||||
tmdbId: tmdbId,
|
||||
imdbId: imdbIds[0], // this is used for one AniDB -> one imdb movie mapping
|
||||
};
|
||||
|
||||
if (tvdbId) {
|
||||
const mappingList = anime['mapping-list'];
|
||||
if (mappingList && mappingList.length != 0) {
|
||||
let imdbIndex = 0;
|
||||
for (const mapping of mappingList[0].mapping) {
|
||||
const text = mapping._;
|
||||
if (text && mapping.$.tvdbseason === '0') {
|
||||
let matches;
|
||||
while ((matches = mappingRegexp.exec(text)) !== null) {
|
||||
const episode = Number(matches[1]);
|
||||
if (!this.specials[tvdbId]) {
|
||||
this.specials[tvdbId] = {};
|
||||
}
|
||||
// map next available imdbid to episode in s0
|
||||
const imdbId =
|
||||
imdbIndex > imdbIds.length ? undefined : imdbIds[imdbIndex];
|
||||
if (tmdbId || imdbId) {
|
||||
this.specials[tvdbId][episode] = {
|
||||
tmdbId: tmdbId,
|
||||
imdbId: imdbId,
|
||||
};
|
||||
imdbIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// some movies do not have mapping-list, so map episode 1,2,3,..to movies
|
||||
// movies must have imdbid or tmdbid
|
||||
const hasImdb = imdbIds.length > 1 || imdbIds[0] !== undefined;
|
||||
if ((hasImdb || tmdbId) && anime.$.defaulttvdbseason === '0') {
|
||||
if (!this.specials[tvdbId]) {
|
||||
this.specials[tvdbId] = {};
|
||||
}
|
||||
// map each imdbid to episode in s0, episode index starts with 1
|
||||
for (let idx = 0; idx < imdbIds.length; idx++) {
|
||||
this.specials[tvdbId][idx + 1] = {
|
||||
tmdbId: tmdbId,
|
||||
imdbId: imdbIds[idx],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.mappingModified = mappingStat.mtime;
|
||||
logger.info(
|
||||
`Loaded ${
|
||||
Object.keys(this.mapping).length
|
||||
} AniDB items from mapping file`,
|
||||
{ label: 'Anime-List Sync' }
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to load Anime-List mappings: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
private downloadFile = async () => {
|
||||
logger.info('Downloading latest mapping file', {
|
||||
label: 'Anime-List Sync',
|
||||
});
|
||||
try {
|
||||
const response = await axios.get(MAPPING_URL, {
|
||||
responseType: 'stream',
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
const writer = fs.createWriteStream(LOCAL_PATH);
|
||||
writer.on('finish', resolve);
|
||||
response.data.pipe(writer);
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to download Anime-List mapping: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public sync = async () => {
|
||||
// make sure only one sync runs at a time
|
||||
if (this.syncing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncing = true;
|
||||
try {
|
||||
// check if local file is not "expired" yet
|
||||
if (fs.existsSync(LOCAL_PATH)) {
|
||||
const now = new Date();
|
||||
const stat = await fsp.stat(LOCAL_PATH);
|
||||
if (now.getTime() - stat.mtime.getTime() < UPDATE_INTERVAL_MSEC) {
|
||||
if (!this.isLoaded()) {
|
||||
// no need to download, but make sure file is loaded
|
||||
await this.loadFromFile();
|
||||
} else if (
|
||||
this.mappingModified &&
|
||||
stat.mtime.getTime() > this.mappingModified.getTime()
|
||||
) {
|
||||
// if file has been modified externally since last load, reload it
|
||||
await this.loadFromFile();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.downloadFile();
|
||||
await this.loadFromFile();
|
||||
} finally {
|
||||
this.syncing = false;
|
||||
}
|
||||
};
|
||||
|
||||
public getFromAnidbId = (anidbId: number): AnidbItem | undefined => {
|
||||
return this.mapping[anidbId];
|
||||
};
|
||||
|
||||
public getSpecialEpisode = (
|
||||
tvdbId: number,
|
||||
episode: number
|
||||
): AnidbItem | undefined => {
|
||||
const episodes = this.specials[tvdbId];
|
||||
return episodes ? episodes[episode] : undefined;
|
||||
};
|
||||
}
|
||||
|
||||
const animeList = new AnimeListMapping();
|
||||
|
||||
export default animeList;
|
||||
@@ -9,6 +9,8 @@ export interface PlexLibraryItem {
|
||||
guid: string;
|
||||
parentGuid?: string;
|
||||
grandparentGuid?: string;
|
||||
addedAt: number;
|
||||
updatedAt: number;
|
||||
type: 'movie' | 'show' | 'season' | 'episode';
|
||||
}
|
||||
|
||||
@@ -48,6 +50,25 @@ export interface PlexMetadata {
|
||||
parentIndex?: number;
|
||||
leafCount: number;
|
||||
viewedLeafCount: number;
|
||||
addedAt: number;
|
||||
updatedAt: number;
|
||||
Media: Media[];
|
||||
}
|
||||
|
||||
interface Media {
|
||||
id: number;
|
||||
duration: number;
|
||||
bitrate: number;
|
||||
width: number;
|
||||
height: number;
|
||||
aspectRatio: number;
|
||||
audioChannels: number;
|
||||
audioCodec: string;
|
||||
videoCodec: string;
|
||||
videoResolution: string;
|
||||
container: string;
|
||||
videoFrameRate: string;
|
||||
videoProfile: string;
|
||||
}
|
||||
|
||||
interface PlexMetadataResponse {
|
||||
@@ -123,6 +144,14 @@ class PlexAPI {
|
||||
return response.MediaContainer.Metadata[0];
|
||||
}
|
||||
|
||||
public async getChildrenMetadata(key: string): Promise<PlexMetadata[]> {
|
||||
const response = await this.plexClient.query<PlexMetadataResponse>(
|
||||
`/library/metadata/${key}/children`
|
||||
);
|
||||
|
||||
return response.MediaContainer.Metadata;
|
||||
}
|
||||
|
||||
public async getRecentlyAdded(id: string): Promise<PlexLibraryItem[]> {
|
||||
const response = await this.plexClient.query<PlexLibraryResponse>(
|
||||
`/library/sections/${id}/recentlyAdded`
|
||||
|
||||
@@ -29,7 +29,7 @@ interface RadarrMovie {
|
||||
hasFile: boolean;
|
||||
}
|
||||
|
||||
interface RadarrRootFolder {
|
||||
export interface RadarrRootFolder {
|
||||
id: number;
|
||||
path: string;
|
||||
freeSpace: number;
|
||||
@@ -40,7 +40,7 @@ interface RadarrRootFolder {
|
||||
}[];
|
||||
}
|
||||
|
||||
interface RadarrProfile {
|
||||
export interface RadarrProfile {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -92,7 +92,27 @@ class RottenTomatoes {
|
||||
}
|
||||
);
|
||||
|
||||
const movie = response.data.movies.find((movie) => movie.year === year);
|
||||
// First, attempt to match exact name and year
|
||||
let movie = response.data.movies.find(
|
||||
(movie) => movie.year === year && movie.title === name
|
||||
);
|
||||
|
||||
// If we don't find a movie, try to match partial name and year
|
||||
if (!movie) {
|
||||
movie = response.data.movies.find(
|
||||
(movie) => movie.year === year && movie.title.includes(name)
|
||||
);
|
||||
}
|
||||
|
||||
// If we still dont find a movie, try to match just on year
|
||||
if (!movie) {
|
||||
movie = response.data.movies.find((movie) => movie.year === year);
|
||||
}
|
||||
|
||||
// One last try, try exact name match only
|
||||
if (!movie) {
|
||||
movie = response.data.movies.find((movie) => movie.title === name);
|
||||
}
|
||||
|
||||
if (!movie) {
|
||||
return null;
|
||||
|
||||
4
server/constants/user.ts
Normal file
4
server/constants/user.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum UserType {
|
||||
PLEX = 1,
|
||||
LOCAL = 2,
|
||||
}
|
||||
@@ -80,6 +80,9 @@ class Media {
|
||||
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
|
||||
public status: MediaStatus;
|
||||
|
||||
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
|
||||
public status4k: MediaStatus;
|
||||
|
||||
@OneToMany(() => MediaRequest, (request) => request.media, { cascade: true })
|
||||
public requests: MediaRequest[];
|
||||
|
||||
@@ -98,6 +101,9 @@ class Media {
|
||||
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||
public lastSeasonChange: Date;
|
||||
|
||||
@Column({ type: 'datetime', nullable: true })
|
||||
public mediaAddedAt: Date;
|
||||
|
||||
constructor(init?: Partial<Media>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,18 @@ export class MediaRequest {
|
||||
})
|
||||
public seasons: SeasonRequest[];
|
||||
|
||||
@Column({ default: false })
|
||||
public is4k: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public serverId: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public profileId: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public rootFolder: string;
|
||||
|
||||
constructor(init?: Partial<MediaRequest>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
@@ -72,11 +84,11 @@ export class MediaRequest {
|
||||
@AfterUpdate()
|
||||
@AfterInsert()
|
||||
public async sendMedia(): Promise<void> {
|
||||
await Promise.all([this._sendToRadarr(), this._sendToSonarr()]);
|
||||
await Promise.all([this.sendToRadarr(), this.sendToSonarr()]);
|
||||
}
|
||||
|
||||
@AfterInsert()
|
||||
private async _notifyNewRequest() {
|
||||
public async notifyNewRequest(): Promise<void> {
|
||||
if (this.status === MediaRequestStatus.PENDING) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const media = await mediaRepository.findOne({
|
||||
@@ -126,8 +138,11 @@ export class MediaRequest {
|
||||
* auto approved content
|
||||
*/
|
||||
@AfterUpdate()
|
||||
private async _notifyApproved() {
|
||||
if (this.status === MediaRequestStatus.APPROVED) {
|
||||
public async notifyApprovedOrDeclined(): Promise<void> {
|
||||
if (
|
||||
this.status === MediaRequestStatus.APPROVED ||
|
||||
this.status === MediaRequestStatus.DECLINED
|
||||
) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: this.media.id },
|
||||
@@ -139,30 +154,40 @@ export class MediaRequest {
|
||||
const tmdb = new TheMovieDb();
|
||||
if (this.media.mediaType === MediaType.MOVIE) {
|
||||
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
|
||||
notificationManager.sendNotification(Notification.MEDIA_APPROVED, {
|
||||
subject: movie.title,
|
||||
message: movie.overview,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
notifyUser: this.requestedBy,
|
||||
media,
|
||||
});
|
||||
notificationManager.sendNotification(
|
||||
this.status === MediaRequestStatus.APPROVED
|
||||
? Notification.MEDIA_APPROVED
|
||||
: Notification.MEDIA_DECLINED,
|
||||
{
|
||||
subject: movie.title,
|
||||
message: movie.overview,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
notifyUser: this.requestedBy,
|
||||
media,
|
||||
}
|
||||
);
|
||||
} else if (this.media.mediaType === MediaType.TV) {
|
||||
const tv = await tmdb.getTvShow({ tvId: this.media.tmdbId });
|
||||
notificationManager.sendNotification(Notification.MEDIA_APPROVED, {
|
||||
subject: tv.name,
|
||||
message: tv.overview,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||
notifyUser: this.requestedBy,
|
||||
media,
|
||||
extra: [
|
||||
{
|
||||
name: 'Seasons',
|
||||
value: this.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
});
|
||||
notificationManager.sendNotification(
|
||||
this.status === MediaRequestStatus.APPROVED
|
||||
? Notification.MEDIA_APPROVED
|
||||
: Notification.MEDIA_DECLINED,
|
||||
{
|
||||
subject: tv.name,
|
||||
message: tv.overview,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||
notifyUser: this.requestedBy,
|
||||
media,
|
||||
extra: [
|
||||
{
|
||||
name: 'Seasons',
|
||||
value: this.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,15 +206,23 @@ export class MediaRequest {
|
||||
}
|
||||
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||
if (this.status === MediaRequestStatus.APPROVED) {
|
||||
media.status = MediaStatus.PROCESSING;
|
||||
if (this.is4k) {
|
||||
media.status4k = MediaStatus.PROCESSING;
|
||||
} else {
|
||||
media.status = MediaStatus.PROCESSING;
|
||||
}
|
||||
mediaRepository.save(media);
|
||||
}
|
||||
|
||||
if (
|
||||
this.media.mediaType === MediaType.MOVIE &&
|
||||
media.mediaType === MediaType.MOVIE &&
|
||||
this.status === MediaRequestStatus.DECLINED
|
||||
) {
|
||||
media.status = MediaStatus.UNKNOWN;
|
||||
if (this.is4k) {
|
||||
media.status4k = MediaStatus.UNKNOWN;
|
||||
} else {
|
||||
media.status = MediaStatus.UNKNOWN;
|
||||
}
|
||||
mediaRepository.save(media);
|
||||
}
|
||||
|
||||
@@ -224,18 +257,31 @@ export class MediaRequest {
|
||||
}
|
||||
|
||||
@AfterRemove()
|
||||
private async _handleRemoveParentUpdate() {
|
||||
public async handleRemoveParentUpdate(): Promise<void> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const fullMedia = await mediaRepository.findOneOrFail({
|
||||
where: { id: this.media.id },
|
||||
relations: ['requests'],
|
||||
});
|
||||
if (!fullMedia.requests || fullMedia.requests.length === 0) {
|
||||
|
||||
if (
|
||||
!fullMedia.requests.some((request) => !request.is4k) &&
|
||||
fullMedia.status !== MediaStatus.AVAILABLE
|
||||
) {
|
||||
fullMedia.status = MediaStatus.UNKNOWN;
|
||||
mediaRepository.save(fullMedia);
|
||||
}
|
||||
|
||||
if (
|
||||
!fullMedia.requests.some((request) => request.is4k) &&
|
||||
fullMedia.status4k !== MediaStatus.AVAILABLE
|
||||
) {
|
||||
fullMedia.status4k = MediaStatus.UNKNOWN;
|
||||
}
|
||||
|
||||
mediaRepository.save(fullMedia);
|
||||
}
|
||||
|
||||
private async _sendToRadarr() {
|
||||
public async sendToRadarr(): Promise<void> {
|
||||
if (
|
||||
this.status === MediaRequestStatus.APPROVED &&
|
||||
this.type === MediaType.MOVIE
|
||||
@@ -251,18 +297,58 @@ export class MediaRequest {
|
||||
return;
|
||||
}
|
||||
|
||||
const radarrSettings = settings.radarr.find(
|
||||
(radarr) => radarr.isDefault && !radarr.is4k
|
||||
let radarrSettings = settings.radarr.find(
|
||||
(radarr) => radarr.isDefault && radarr.is4k === this.is4k
|
||||
);
|
||||
|
||||
if (
|
||||
this.serverId !== null &&
|
||||
this.serverId >= 0 &&
|
||||
radarrSettings?.id !== this.serverId
|
||||
) {
|
||||
radarrSettings = settings.radarr.find(
|
||||
(radarr) => radarr.id === this.serverId
|
||||
);
|
||||
logger.info(
|
||||
`Request has an override server: ${radarrSettings?.name}`,
|
||||
{ label: 'Media Request' }
|
||||
);
|
||||
}
|
||||
|
||||
if (!radarrSettings) {
|
||||
logger.info(
|
||||
'There is no default radarr configured. Did you set any of your Radarr servers as default?',
|
||||
`There is no default ${
|
||||
this.is4k ? '4K ' : ''
|
||||
}radarr configured. Did you set any of your Radarr servers as default?`,
|
||||
{ label: 'Media Request' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let rootFolder = radarrSettings.activeDirectory;
|
||||
let qualityProfile = radarrSettings.activeProfileId;
|
||||
|
||||
if (
|
||||
this.rootFolder &&
|
||||
this.rootFolder !== '' &&
|
||||
this.rootFolder !== radarrSettings.activeDirectory
|
||||
) {
|
||||
rootFolder = this.rootFolder;
|
||||
logger.info(`Request has an override root folder: ${rootFolder}`, {
|
||||
label: 'Media Request',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
this.profileId &&
|
||||
this.profileId !== radarrSettings.activeProfileId
|
||||
) {
|
||||
qualityProfile = this.profileId;
|
||||
logger.info(`Request has an override profile id: ${qualityProfile}`, {
|
||||
label: 'Media Request',
|
||||
});
|
||||
}
|
||||
|
||||
const tmdb = new TheMovieDb();
|
||||
const radarr = new RadarrAPI({
|
||||
apiKey: radarrSettings.apiKey,
|
||||
@@ -275,9 +361,9 @@ export class MediaRequest {
|
||||
// Run this asynchronously so we don't wait for it on the UI side
|
||||
radarr
|
||||
.addMovie({
|
||||
profileId: radarrSettings.activeProfileId,
|
||||
qualityProfileId: radarrSettings.activeProfileId,
|
||||
rootFolderPath: radarrSettings.activeDirectory,
|
||||
profileId: qualityProfile,
|
||||
qualityProfileId: qualityProfile,
|
||||
rootFolderPath: rootFolder,
|
||||
minimumAvailability: radarrSettings.minimumAvailability,
|
||||
title: movie.title,
|
||||
tmdbId: movie.id,
|
||||
@@ -325,7 +411,7 @@ export class MediaRequest {
|
||||
}
|
||||
}
|
||||
|
||||
private async _sendToSonarr() {
|
||||
public async sendToSonarr(): Promise<void> {
|
||||
if (
|
||||
this.status === MediaRequestStatus.APPROVED &&
|
||||
this.type === MediaType.TV
|
||||
@@ -341,13 +427,29 @@ export class MediaRequest {
|
||||
return;
|
||||
}
|
||||
|
||||
const sonarrSettings = settings.sonarr.find(
|
||||
(sonarr) => sonarr.isDefault && !sonarr.is4k
|
||||
let sonarrSettings = settings.sonarr.find(
|
||||
(sonarr) => sonarr.isDefault && sonarr.is4k === this.is4k
|
||||
);
|
||||
|
||||
if (
|
||||
this.serverId !== null &&
|
||||
this.serverId >= 0 &&
|
||||
sonarrSettings?.id !== this.serverId
|
||||
) {
|
||||
sonarrSettings = settings.sonarr.find(
|
||||
(sonarr) => sonarr.id === this.serverId
|
||||
);
|
||||
logger.info(
|
||||
`Request has an override server: ${sonarrSettings?.name}`,
|
||||
{ label: 'Media Request' }
|
||||
);
|
||||
}
|
||||
|
||||
if (!sonarrSettings) {
|
||||
logger.info(
|
||||
'There is no default sonarr configured. Did you set any of your Sonarr servers as default?',
|
||||
`There is no default ${
|
||||
this.is4k ? '4K ' : ''
|
||||
}sonarr configured. Did you set any of your Sonarr servers as default?`,
|
||||
{ label: 'Media Request' }
|
||||
);
|
||||
return;
|
||||
@@ -386,17 +488,38 @@ export class MediaRequest {
|
||||
seriesType = 'anime';
|
||||
}
|
||||
|
||||
let rootFolder =
|
||||
seriesType === 'anime' && sonarrSettings.activeAnimeDirectory
|
||||
? sonarrSettings.activeAnimeDirectory
|
||||
: sonarrSettings.activeDirectory;
|
||||
let qualityProfile =
|
||||
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
|
||||
? sonarrSettings.activeAnimeProfileId
|
||||
: sonarrSettings.activeProfileId;
|
||||
|
||||
if (
|
||||
this.rootFolder &&
|
||||
this.rootFolder !== '' &&
|
||||
this.rootFolder !== rootFolder
|
||||
) {
|
||||
rootFolder = this.rootFolder;
|
||||
logger.info(`Request has an override root folder: ${rootFolder}`, {
|
||||
label: 'Media Request',
|
||||
});
|
||||
}
|
||||
|
||||
if (this.profileId && this.profileId !== qualityProfile) {
|
||||
qualityProfile = this.profileId;
|
||||
logger.info(`Request has an override profile id: ${qualityProfile}`, {
|
||||
label: 'Media Request',
|
||||
});
|
||||
}
|
||||
|
||||
// Run this asynchronously so we don't wait for it on the UI side
|
||||
sonarr
|
||||
.addSeries({
|
||||
profileId:
|
||||
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
|
||||
? sonarrSettings.activeAnimeProfileId
|
||||
: sonarrSettings.activeProfileId,
|
||||
rootFolderPath:
|
||||
seriesType === 'anime' && sonarrSettings.activeAnimeDirectory
|
||||
? sonarrSettings.activeAnimeDirectory
|
||||
: sonarrSettings.activeDirectory,
|
||||
profileId: qualityProfile,
|
||||
rootFolderPath: rootFolder,
|
||||
title: series.name,
|
||||
tvdbid: series.external_ids.tvdb_id,
|
||||
seasons: this.seasons.map((season) => season.seasonNumber),
|
||||
|
||||
@@ -20,6 +20,9 @@ class Season {
|
||||
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
|
||||
public status: MediaStatus;
|
||||
|
||||
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
|
||||
public status4k: MediaStatus;
|
||||
|
||||
@ManyToOne(() => Media, (media) => media.seasons, { onDelete: 'CASCADE' })
|
||||
public media: Promise<Media>;
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@ import {
|
||||
} from 'typeorm';
|
||||
import { Permission, hasPermission } from '../lib/permissions';
|
||||
import { MediaRequest } from './MediaRequest';
|
||||
import bcrypt from 'bcrypt';
|
||||
import path from 'path';
|
||||
import PreparedEmail from '../lib/email';
|
||||
import logger from '../logger';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import { default as generatePassword } from 'secure-random-password';
|
||||
import { UserType } from '../constants/user';
|
||||
|
||||
@Entity()
|
||||
export class User {
|
||||
@@ -16,7 +23,7 @@ export class User {
|
||||
return users.map((u) => u.filter());
|
||||
}
|
||||
|
||||
static readonly filteredFields: string[] = ['plexToken'];
|
||||
static readonly filteredFields: string[] = ['plexToken', 'password'];
|
||||
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
@@ -27,8 +34,14 @@ export class User {
|
||||
@Column()
|
||||
public username: string;
|
||||
|
||||
@Column({ select: false })
|
||||
public plexId: number;
|
||||
@Column({ nullable: true, select: false })
|
||||
public password?: string;
|
||||
|
||||
@Column({ type: 'integer', default: UserType.PLEX })
|
||||
public userType: UserType;
|
||||
|
||||
@Column({ nullable: true, select: false })
|
||||
public plexId?: number;
|
||||
|
||||
@Column({ nullable: true, select: false })
|
||||
public plexToken?: string;
|
||||
@@ -69,4 +82,47 @@ export class User {
|
||||
public hasPermission(permissions: Permission | Permission[]): boolean {
|
||||
return !!hasPermission(permissions, this.permissions);
|
||||
}
|
||||
|
||||
public passwordMatch(password: string): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.password) {
|
||||
resolve(bcrypt.compare(password, this.password));
|
||||
} else {
|
||||
return reject(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async setPassword(password: string): Promise<void> {
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
this.password = hashedPassword;
|
||||
}
|
||||
|
||||
public async resetPassword(): Promise<void> {
|
||||
const password = generatePassword.randomPassword({ length: 16 });
|
||||
this.setPassword(password);
|
||||
|
||||
const applicationUrl = getSettings().main.applicationUrl;
|
||||
try {
|
||||
logger.info(`Sending password email for ${this.email}`, {
|
||||
label: 'User creation',
|
||||
});
|
||||
const email = new PreparedEmail();
|
||||
await email.send({
|
||||
template: path.join(__dirname, '../templates/email/password'),
|
||||
message: {
|
||||
to: this.email,
|
||||
},
|
||||
locals: {
|
||||
password: password,
|
||||
applicationUrl,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Failed to send out password email', {
|
||||
label: 'User creation',
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import TelegramAgent from './lib/notifications/agents/telegram';
|
||||
import { getAppVersion } from './utils/appVersion';
|
||||
import SlackAgent from './lib/notifications/agents/slack';
|
||||
import PushoverAgent from './lib/notifications/agents/pushover';
|
||||
import WebhookAgent from './lib/notifications/agents/webhook';
|
||||
|
||||
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
|
||||
|
||||
@@ -51,6 +52,7 @@ app
|
||||
new SlackAgent(),
|
||||
new TelegramAgent(),
|
||||
new PushoverAgent(),
|
||||
new WebhookAgent(),
|
||||
]);
|
||||
|
||||
// Start Jobs
|
||||
|
||||
18
server/interfaces/api/serviceInterfaces.ts
Normal file
18
server/interfaces/api/serviceInterfaces.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { RadarrProfile, RadarrRootFolder } from '../../api/radarr';
|
||||
|
||||
export interface ServiceCommonServer {
|
||||
id: number;
|
||||
name: string;
|
||||
is4k: boolean;
|
||||
isDefault: boolean;
|
||||
activeProfileId: number;
|
||||
activeDirectory: string;
|
||||
activeAnimeProfileId?: number;
|
||||
activeAnimeDirectory?: string;
|
||||
}
|
||||
|
||||
export interface ServiceCommonServerWithDetails {
|
||||
server: ServiceCommonServer;
|
||||
profiles: RadarrProfile[];
|
||||
rootFolders: Partial<RadarrRootFolder>[];
|
||||
}
|
||||
@@ -4,3 +4,9 @@ export interface SettingsAboutResponse {
|
||||
totalMediaItems: number;
|
||||
tz?: string;
|
||||
}
|
||||
|
||||
export interface PublicSettingsResponse {
|
||||
initialized: boolean;
|
||||
movie4kEnabled: boolean;
|
||||
series4kEnabled: boolean;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getRepository } from 'typeorm';
|
||||
import { User } from '../../entity/User';
|
||||
import PlexAPI, { PlexLibraryItem } from '../../api/plexapi';
|
||||
import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../api/plexapi';
|
||||
import TheMovieDb, {
|
||||
TmdbMovieDetails,
|
||||
TmdbTvDetails,
|
||||
@@ -11,15 +11,23 @@ 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]+)|hama:\/\/tvdb-([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;
|
||||
@@ -30,6 +38,7 @@ interface SyncStatus {
|
||||
}
|
||||
|
||||
class JobPlexSync {
|
||||
private sessionId: string;
|
||||
private tmdb: TheMovieDb;
|
||||
private plexClient: PlexAPI;
|
||||
private items: PlexLibraryItem[] = [];
|
||||
@@ -38,6 +47,9 @@ class JobPlexSync {
|
||||
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();
|
||||
@@ -78,26 +90,71 @@ class JobPlexSync {
|
||||
}
|
||||
});
|
||||
|
||||
const existing = await this.getExisting(
|
||||
newMedia.tmdbId,
|
||||
MediaType.MOVIE
|
||||
const has4k = metadata.Media.some(
|
||||
(media) => media.videoResolution === '4k'
|
||||
);
|
||||
const hasOtherResolution = metadata.Media.some(
|
||||
(media) => media.videoResolution !== '4k'
|
||||
);
|
||||
|
||||
if (existing && existing.status === MediaStatus.AVAILABLE) {
|
||||
this.log(`Title exists and is already available ${metadata.title}`);
|
||||
} else if (existing && existing.status !== MediaStatus.AVAILABLE) {
|
||||
existing.status = MediaStatus.AVAILABLE;
|
||||
mediaRepository.save(existing);
|
||||
this.log(
|
||||
`Request for ${metadata.title} exists. Setting status AVAILABLE`,
|
||||
'info'
|
||||
await this.asyncLock.dispatch(newMedia.tmdbId, async () => {
|
||||
const existing = await this.getExisting(
|
||||
newMedia.tmdbId,
|
||||
MediaType.MOVIE
|
||||
);
|
||||
} else {
|
||||
newMedia.status = MediaStatus.AVAILABLE;
|
||||
newMedia.mediaType = MediaType.MOVIE;
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${plexitem.title}`);
|
||||
}
|
||||
|
||||
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 (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);
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${plexitem.title}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
let tmdbMovieId: number | undefined;
|
||||
let tmdbMovie: TmdbMovieDetails | undefined;
|
||||
@@ -118,30 +175,7 @@ class JobPlexSync {
|
||||
throw new Error('Unable to find TMDB ID');
|
||||
}
|
||||
|
||||
const existing = await this.getExisting(tmdbMovieId, MediaType.MOVIE);
|
||||
if (existing && existing.status === MediaStatus.AVAILABLE) {
|
||||
this.log(`Title exists and is already available ${plexitem.title}`);
|
||||
} else if (existing && existing.status !== MediaStatus.AVAILABLE) {
|
||||
existing.status = MediaStatus.AVAILABLE;
|
||||
await mediaRepository.save(existing);
|
||||
this.log(
|
||||
`Request for ${plexitem.title} exists. Setting status AVAILABLE`,
|
||||
'info'
|
||||
);
|
||||
} 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.status = MediaStatus.AVAILABLE;
|
||||
newMedia.mediaType = MediaType.MOVIE;
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${tmdbMovie.title}`);
|
||||
}
|
||||
await this.processMovieWithId(plexitem, tmdbMovie, tmdbMovieId);
|
||||
}
|
||||
} catch (e) {
|
||||
this.log(
|
||||
@@ -155,6 +189,113 @@ class JobPlexSync {
|
||||
}
|
||||
}
|
||||
|
||||
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 (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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async processShow(plexitem: PlexLibraryItem) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
@@ -182,108 +323,272 @@ class JobPlexSync {
|
||||
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 (tvShow && metadata) {
|
||||
// Lets get the available seasons from plex
|
||||
const seasons = tvShow.seasons;
|
||||
const media = await this.getExisting(tvShow.id, MediaType.TV);
|
||||
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);
|
||||
|
||||
const newSeasons: Season[] = [];
|
||||
|
||||
const currentSeasonAvailable = (
|
||||
media?.seasons.filter(
|
||||
(season) => season.status === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
seasons.forEach((season) => {
|
||||
const matchedPlexSeason = metadata.Children?.Metadata.find(
|
||||
(md) => Number(md.index) === season.season_number
|
||||
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);
|
||||
|
||||
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 &&
|
||||
Number(matchedPlexSeason.leafCount) === season.episode_count
|
||||
) {
|
||||
if (existingSeason) {
|
||||
existingSeason.status = MediaStatus.AVAILABLE;
|
||||
// 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 {
|
||||
newSeasons.push(
|
||||
new Season({
|
||||
seasonNumber: season.season_number,
|
||||
status: MediaStatus.AVAILABLE,
|
||||
})
|
||||
this.log(
|
||||
`Missing TVDB ${result.tvdbId} entry in TMDB for AniDB ${anidbId}`
|
||||
);
|
||||
}
|
||||
} else if (matchedPlexSeason) {
|
||||
if (existingSeason) {
|
||||
existingSeason.status = MediaStatus.PARTIALLY_AVAILABLE;
|
||||
} else {
|
||||
newSeasons.push(
|
||||
new Season({
|
||||
seasonNumber: season.season_number,
|
||||
status: MediaStatus.PARTIALLY_AVAILABLE,
|
||||
})
|
||||
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.processMovieWithId(
|
||||
plexitem,
|
||||
undefined,
|
||||
result.tmdbId
|
||||
);
|
||||
} else if (result?.imdbId) {
|
||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
||||
imdbId: result.imdbId,
|
||||
});
|
||||
return await this.processMovieWithId(
|
||||
plexitem,
|
||||
tmdbMovie,
|
||||
tmdbMovie.id
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove extras season. We dont count it for determining availability
|
||||
const filteredSeasons = tvShow.seasons.filter(
|
||||
(season) => season.season_number !== 0
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
const isAllSeasons =
|
||||
newSeasons.length + (media?.seasons.length ?? 0) >=
|
||||
filteredSeasons.length;
|
||||
// Lets get the available seasons from plex
|
||||
const seasons = tvShow.seasons;
|
||||
const media = await this.getExisting(tvShow.id, MediaType.TV);
|
||||
|
||||
if (media) {
|
||||
// Update existing
|
||||
media.seasons = [...media.seasons, ...newSeasons];
|
||||
const newSeasons: Season[] = [];
|
||||
|
||||
const newSeasonAvailable = (
|
||||
media.seasons.filter(
|
||||
const currentStandardSeasonAvailable = (
|
||||
media?.seasons.filter(
|
||||
(season) => season.status === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
const current4kSeasonAvailable = (
|
||||
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 (newSeasonAvailable > currentSeasonAvailable) {
|
||||
this.log(
|
||||
`Detected ${
|
||||
newSeasonAvailable - currentSeasonAvailable
|
||||
} new season(s) for ${tvShow.name}`,
|
||||
'debug'
|
||||
for (const season of seasons) {
|
||||
const matchedPlexSeason = metadata.Children?.Metadata.find(
|
||||
(md) => Number(md.index) === season.season_number
|
||||
);
|
||||
media.lastSeasonChange = new Date();
|
||||
|
||||
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) =>
|
||||
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 (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
|
||||
existingSeason.status =
|
||||
totalStandard === season.episode_count
|
||||
? MediaStatus.AVAILABLE
|
||||
: totalStandard > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: existingSeason.status;
|
||||
existingSeason.status4k =
|
||||
total4k === season.episode_count
|
||||
? MediaStatus.AVAILABLE
|
||||
: 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:
|
||||
total4k === season.episode_count
|
||||
? MediaStatus.AVAILABLE
|
||||
: total4k > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
media.status = isAllSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: MediaStatus.PARTIALLY_AVAILABLE;
|
||||
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,
|
||||
status: isAllSeasons
|
||||
// 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);
|
||||
}
|
||||
|
||||
media.status = isAllStandardSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: MediaStatus.PARTIALLY_AVAILABLE,
|
||||
});
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${tvShow.name}`);
|
||||
}
|
||||
: media.seasons.some(
|
||||
(season) => season.status !== MediaStatus.UNKNOWN
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN;
|
||||
media.status4k = isAll4kSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: media.seasons.some(
|
||||
(season) => season.status4k !== MediaStatus.UNKNOWN
|
||||
)
|
||||
? 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.UNKNOWN
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k: isAll4kSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: newSeasons.some(
|
||||
(season) => season.status4k !== MediaStatus.UNKNOWN
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN,
|
||||
});
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${tvShow.name}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.log(`failed show: ${plexitem.guid}`);
|
||||
}
|
||||
@@ -322,22 +627,35 @@ class JobPlexSync {
|
||||
private async loop({
|
||||
start = 0,
|
||||
end = BUNDLE_SIZE,
|
||||
sessionId,
|
||||
}: {
|
||||
start?: number;
|
||||
end?: number;
|
||||
sessionId?: string;
|
||||
} = {}) {
|
||||
const slicedItems = this.items.slice(start, end);
|
||||
if (start < this.items.length && this.running) {
|
||||
|
||||
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) =>
|
||||
setTimeout(async () => {
|
||||
await this.loop({
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
setTimeout(() => {
|
||||
this.loop({
|
||||
start: start + BUNDLE_SIZE,
|
||||
end: end + BUNDLE_SIZE,
|
||||
});
|
||||
resolve();
|
||||
sessionId,
|
||||
})
|
||||
.then(() => resolve())
|
||||
.catch((e) => reject(new Error(e.message)));
|
||||
}, UPDATE_RATE)
|
||||
);
|
||||
}
|
||||
@@ -351,9 +669,23 @@ class JobPlexSync {
|
||||
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();
|
||||
if (!this.running) {
|
||||
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({
|
||||
@@ -371,6 +703,27 @@ class JobPlexSync {
|
||||
(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;
|
||||
@@ -397,18 +750,31 @@ class JobPlexSync {
|
||||
return mediaA.ratingKey === mediaB.ratingKey;
|
||||
});
|
||||
|
||||
await this.loop();
|
||||
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();
|
||||
await this.loop({ sessionId });
|
||||
}
|
||||
}
|
||||
this.running = false;
|
||||
this.log('complete');
|
||||
this.log(
|
||||
this.isRecentOnly
|
||||
? 'Recently Added Scan Complete'
|
||||
: 'Full Scan Complete'
|
||||
);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
38
server/lib/email/index.ts
Normal file
38
server/lib/email/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import Email from 'email-templates';
|
||||
import { getSettings } from '../settings';
|
||||
class PreparedEmail extends Email {
|
||||
public constructor() {
|
||||
const settings = getSettings().notifications.agents.email;
|
||||
|
||||
const transport = nodemailer.createTransport({
|
||||
host: settings.options.smtpHost,
|
||||
port: settings.options.smtpPort,
|
||||
secure: settings.options.secure,
|
||||
tls: settings.options.allowSelfSigned
|
||||
? {
|
||||
rejectUnauthorized: false,
|
||||
}
|
||||
: undefined,
|
||||
auth:
|
||||
settings.options.authUser && settings.options.authPass
|
||||
? {
|
||||
user: settings.options.authUser,
|
||||
pass: settings.options.authPass,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
super({
|
||||
message: {
|
||||
from: {
|
||||
name: settings.options.senderName,
|
||||
address: settings.options.emailFrom,
|
||||
},
|
||||
},
|
||||
send: true,
|
||||
transport: transport,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default PreparedEmail;
|
||||
@@ -158,6 +158,28 @@ class DiscordAgent
|
||||
}
|
||||
);
|
||||
|
||||
if (settings.main.applicationUrl) {
|
||||
fields.push({
|
||||
name: 'View Media',
|
||||
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
color = EmbedColors.RED;
|
||||
fields.push(
|
||||
{
|
||||
name: 'Requested By',
|
||||
value: payload.notifyUser.username ?? '',
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: 'Status',
|
||||
value: 'Declined',
|
||||
inline: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (settings.main.applicationUrl) {
|
||||
fields.push({
|
||||
name: 'View Media',
|
||||
|
||||
@@ -2,12 +2,11 @@ import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import path from 'path';
|
||||
import { getSettings, NotificationAgentEmail } from '../../settings';
|
||||
import nodemailer from 'nodemailer';
|
||||
import Email from 'email-templates';
|
||||
import logger from '../../../logger';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { User } from '../../../entity/User';
|
||||
import { Permission } from '../../permissions';
|
||||
import PreparedEmail from '../../email';
|
||||
|
||||
class EmailAgent
|
||||
extends BaseAgent<NotificationAgentEmail>
|
||||
@@ -35,42 +34,6 @@ class EmailAgent
|
||||
return false;
|
||||
}
|
||||
|
||||
private getSmtpTransport() {
|
||||
const emailSettings = this.getSettings().options;
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host: emailSettings.smtpHost,
|
||||
port: emailSettings.smtpPort,
|
||||
secure: emailSettings.secure,
|
||||
tls: emailSettings.allowSelfSigned
|
||||
? {
|
||||
rejectUnauthorized: false,
|
||||
}
|
||||
: undefined,
|
||||
auth:
|
||||
emailSettings.authUser && emailSettings.authPass
|
||||
? {
|
||||
user: emailSettings.authUser,
|
||||
pass: emailSettings.authPass,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private getNewEmail() {
|
||||
const settings = this.getSettings();
|
||||
return new Email({
|
||||
message: {
|
||||
from: {
|
||||
name: settings.options.senderName,
|
||||
address: settings.options.emailFrom,
|
||||
},
|
||||
},
|
||||
send: true,
|
||||
transport: this.getSmtpTransport(),
|
||||
});
|
||||
}
|
||||
|
||||
private async sendMediaRequestEmail(payload: NotificationPayload) {
|
||||
// This is getting main settings for the whole app
|
||||
const applicationUrl = getSettings().main.applicationUrl;
|
||||
@@ -82,7 +45,7 @@ class EmailAgent
|
||||
users
|
||||
.filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS))
|
||||
.forEach((user) => {
|
||||
const email = this.getNewEmail();
|
||||
const email = new PreparedEmail();
|
||||
|
||||
email.send({
|
||||
template: path.join(
|
||||
@@ -127,7 +90,7 @@ class EmailAgent
|
||||
users
|
||||
.filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS))
|
||||
.forEach((user) => {
|
||||
const email = this.getNewEmail();
|
||||
const email = new PreparedEmail();
|
||||
|
||||
email.send({
|
||||
template: path.join(
|
||||
@@ -166,7 +129,7 @@ class EmailAgent
|
||||
// This is getting main settings for the whole app
|
||||
const applicationUrl = getSettings().main.applicationUrl;
|
||||
try {
|
||||
const email = this.getNewEmail();
|
||||
const email = new PreparedEmail();
|
||||
|
||||
await email.send({
|
||||
template: path.join(
|
||||
@@ -199,11 +162,48 @@ class EmailAgent
|
||||
}
|
||||
}
|
||||
|
||||
private async sendMediaDeclinedEmail(payload: NotificationPayload) {
|
||||
// This is getting main settings for the whole app
|
||||
const applicationUrl = getSettings().main.applicationUrl;
|
||||
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 was declined:',
|
||||
mediaName: payload.subject,
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
requestedBy: payload.notifyUser.username,
|
||||
actionUrl: applicationUrl
|
||||
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
|
||||
: undefined,
|
||||
applicationUrl,
|
||||
requestType: 'Request Declined',
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Mail notification failed to send', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendMediaAvailableEmail(payload: NotificationPayload) {
|
||||
// This is getting main settings for the whole app
|
||||
const applicationUrl = getSettings().main.applicationUrl;
|
||||
try {
|
||||
const email = this.getNewEmail();
|
||||
const email = new PreparedEmail();
|
||||
|
||||
await email.send({
|
||||
template: path.join(
|
||||
@@ -240,7 +240,7 @@ class EmailAgent
|
||||
// This is getting main settings for the whole app
|
||||
const applicationUrl = getSettings().main.applicationUrl;
|
||||
try {
|
||||
const email = this.getNewEmail();
|
||||
const email = new PreparedEmail();
|
||||
|
||||
await email.send({
|
||||
template: path.join(__dirname, '../../../templates/email/test-email'),
|
||||
@@ -275,6 +275,9 @@ class EmailAgent
|
||||
case Notification.MEDIA_APPROVED:
|
||||
this.sendMediaApprovedEmail(payload);
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
this.sendMediaDeclinedEmail(payload);
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
this.sendMediaAvailableEmail(payload);
|
||||
break;
|
||||
|
||||
@@ -72,6 +72,13 @@ class PushoverAgent
|
||||
message += `<b>Requested By</b>\n${user}\n\n`;
|
||||
message += `<b>Status</b>\nAvailable\n`;
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
messageTitle = 'Request Declined';
|
||||
message += `${title}\n\n`;
|
||||
message += `${plot}\n\n`;
|
||||
message += `<b>Requested By</b>\n${user}\n\n`;
|
||||
message += `<b>Status</b>\nDeclined\n`;
|
||||
break;
|
||||
case Notification.TEST_NOTIFICATION:
|
||||
messageTitle = 'Test Notification';
|
||||
message += `${title}\n\n`;
|
||||
|
||||
@@ -96,6 +96,22 @@ class SlackAgent
|
||||
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
|
||||
}
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
header = 'Request Declined';
|
||||
fields.push(
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: `*Requested By*\n${payload.notifyUser.username ?? ''}`,
|
||||
},
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nDeclined',
|
||||
}
|
||||
);
|
||||
if (settings.main.applicationUrl) {
|
||||
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
|
||||
}
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
header = 'Now available!';
|
||||
fields.push(
|
||||
|
||||
@@ -70,6 +70,14 @@ class TelegramAgent
|
||||
message += `\*Requested By\*\n${user}\n\n`;
|
||||
message += `\*Status\*\nProcessing Request\n`;
|
||||
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
message += `\*Request Declined\*\n`;
|
||||
message += `${title}\n\n`;
|
||||
message += `${plot}\n\n`;
|
||||
message += `\*Requested By\*\n${user}\n\n`;
|
||||
message += `\*Status\*\nDeclined\n`;
|
||||
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
message += `\*Now available\\!\*\n`;
|
||||
|
||||
139
server/lib/notifications/agents/webhook.ts
Normal file
139
server/lib/notifications/agents/webhook.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import axios from 'axios';
|
||||
import { get } from 'lodash';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { MediaStatus } from '../../../constants/media';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentWebhook } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
|
||||
type KeyMapFunction = (
|
||||
payload: NotificationPayload,
|
||||
type: Notification
|
||||
) => string;
|
||||
|
||||
const KeyMap: Record<string, string | KeyMapFunction> = {
|
||||
notification_type: (_payload, type) => Notification[type],
|
||||
subject: 'subject',
|
||||
message: 'message',
|
||||
image: 'image',
|
||||
notifyuser_username: 'notifyUser.username',
|
||||
notifyuser_email: 'notifyUser.email',
|
||||
notifyuser_avatar: 'notifyUser.avatar',
|
||||
media_tmdbid: 'media.tmdbId',
|
||||
media_imdbid: 'media.imdbId',
|
||||
media_tvdbid: 'media.tvdbId',
|
||||
media_type: 'media.mediaType',
|
||||
media_status: (payload) =>
|
||||
payload.media?.status ? MediaStatus[payload.media?.status] : '',
|
||||
media_status4k: (payload) =>
|
||||
payload.media?.status ? MediaStatus[payload.media?.status4k] : '',
|
||||
};
|
||||
|
||||
class WebhookAgent
|
||||
extends BaseAgent<NotificationAgentWebhook>
|
||||
implements NotificationAgent {
|
||||
protected getSettings(): NotificationAgentWebhook {
|
||||
if (this.settings) {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
|
||||
return settings.notifications.agents.webhook;
|
||||
}
|
||||
|
||||
private parseKeys(
|
||||
finalPayload: Record<string, unknown>,
|
||||
payload: NotificationPayload,
|
||||
type: Notification
|
||||
): Record<string, unknown> {
|
||||
Object.keys(finalPayload).forEach((key) => {
|
||||
if (key === '{{extra}}') {
|
||||
finalPayload.extra = payload.extra ?? [];
|
||||
delete finalPayload[key];
|
||||
key = 'extra';
|
||||
} else if (key === '{{media}}') {
|
||||
if (payload.media) {
|
||||
finalPayload.media = finalPayload[key];
|
||||
} else {
|
||||
finalPayload.media = null;
|
||||
}
|
||||
delete finalPayload[key];
|
||||
key = 'media';
|
||||
}
|
||||
|
||||
if (typeof finalPayload[key] === 'string') {
|
||||
Object.keys(KeyMap).forEach((keymapKey) => {
|
||||
const keymapValue = KeyMap[keymapKey as keyof typeof KeyMap];
|
||||
finalPayload[key] = (finalPayload[key] as string).replace(
|
||||
`{{${keymapKey}}}`,
|
||||
typeof keymapValue === 'function'
|
||||
? keymapValue(payload, type)
|
||||
: get(payload, keymapValue) ?? ''
|
||||
);
|
||||
});
|
||||
} else if (finalPayload[key] && typeof finalPayload[key] === 'object') {
|
||||
finalPayload[key] = this.parseKeys(
|
||||
finalPayload[key] as Record<string, unknown>,
|
||||
payload,
|
||||
type
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return finalPayload;
|
||||
}
|
||||
|
||||
private buildPayload(type: Notification, payload: NotificationPayload) {
|
||||
const payloadString = Buffer.from(
|
||||
this.getSettings().options.jsonPayload,
|
||||
'base64'
|
||||
).toString('ascii');
|
||||
|
||||
const parsedJSON = JSON.parse(JSON.parse(payloadString));
|
||||
|
||||
return this.parseKeys(parsedJSON, payload, type);
|
||||
}
|
||||
|
||||
public shouldSend(type: Notification): boolean {
|
||||
if (
|
||||
this.getSettings().enabled &&
|
||||
this.getSettings().options.webhookUrl &&
|
||||
hasNotificationType(type, this.getSettings().types)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async send(
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
logger.debug('Sending webhook notification', { label: 'Notifications' });
|
||||
try {
|
||||
const { webhookUrl, authHeader } = this.getSettings().options;
|
||||
|
||||
if (!webhookUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await axios.post(webhookUrl, this.buildPayload(type, payload), {
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Error sending Webhook notification', {
|
||||
label: 'Notifications',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default WebhookAgent;
|
||||
@@ -7,6 +7,7 @@ export enum Notification {
|
||||
MEDIA_AVAILABLE = 8,
|
||||
MEDIA_FAILED = 16,
|
||||
TEST_NOTIFICATION = 32,
|
||||
MEDIA_DECLINED = 64,
|
||||
}
|
||||
|
||||
export const hasNotificationType = (
|
||||
|
||||
@@ -9,6 +9,10 @@ export enum Permission {
|
||||
AUTO_APPROVE = 128,
|
||||
AUTO_APPROVE_MOVIE = 256,
|
||||
AUTO_APPROVE_TV = 512,
|
||||
REQUEST_4K = 1024,
|
||||
REQUEST_4K_MOVIE = 2048,
|
||||
REQUEST_4K_TV = 4096,
|
||||
REQUEST_ADVANCED = 8192,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -55,6 +55,11 @@ interface PublicSettings {
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
interface FullPublicSettings extends PublicSettings {
|
||||
movie4kEnabled: boolean;
|
||||
series4kEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationAgentConfig {
|
||||
enabled: boolean;
|
||||
types: number;
|
||||
@@ -101,12 +106,21 @@ export interface NotificationAgentPushover extends NotificationAgentConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export interface NotificationAgentWebhook extends NotificationAgentConfig {
|
||||
options: {
|
||||
webhookUrl: string;
|
||||
jsonPayload: string;
|
||||
authHeader: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface NotificationAgents {
|
||||
email: NotificationAgentEmail;
|
||||
discord: NotificationAgentDiscord;
|
||||
slack: NotificationAgentSlack;
|
||||
telegram: NotificationAgentTelegram;
|
||||
pushover: NotificationAgentPushover;
|
||||
webhook: NotificationAgentWebhook;
|
||||
}
|
||||
|
||||
interface NotificationSettings {
|
||||
@@ -123,7 +137,9 @@ interface AllSettings {
|
||||
notifications: NotificationSettings;
|
||||
}
|
||||
|
||||
const SETTINGS_PATH = path.join(__dirname, '../../config/settings.json');
|
||||
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
|
||||
? `${process.env.CONFIG_DIRECTORY}/settings.json`
|
||||
: path.join(__dirname, '../../config/settings.json');
|
||||
|
||||
class Settings {
|
||||
private data: AllSettings;
|
||||
@@ -194,6 +210,16 @@ class Settings {
|
||||
sound: '',
|
||||
},
|
||||
},
|
||||
webhook: {
|
||||
enabled: false,
|
||||
types: 0,
|
||||
options: {
|
||||
webhookUrl: '',
|
||||
authHeader: '',
|
||||
jsonPayload:
|
||||
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJzdWJqZWN0XCI6IFwie3tzdWJqZWN0fX1cIixcbiAgICBcIm1lc3NhZ2VcIjogXCJ7e21lc3NhZ2V9fVwiLFxuICAgIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgICBcImVtYWlsXCI6IFwie3tub3RpZnl1c2VyX2VtYWlsfX1cIixcbiAgICBcInVzZXJuYW1lXCI6IFwie3tub3RpZnl1c2VyX3VzZXJuYW1lfX1cIixcbiAgICBcImF2YXRhclwiOiBcInt7bm90aWZ5dXNlcl9hdmF0YXJ9fVwiLFxuICAgIFwie3ttZWRpYX19XCI6IHtcbiAgICAgICAgXCJtZWRpYV90eXBlXCI6IFwie3ttZWRpYV90eXBlfX1cIixcbiAgICAgICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgICAgIFwiaW1kYklkXCI6IFwie3ttZWRpYV9pbWRiaWR9fVwiLFxuICAgICAgICBcInR2ZGJJZFwiOiBcInt7bWVkaWFfdHZkYmlkfX1cIixcbiAgICAgICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgICAgIFwic3RhdHVzNGtcIjogXCJ7e21lZGlhX3N0YXR1czRrfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -246,6 +272,18 @@ class Settings {
|
||||
this.data.public = data;
|
||||
}
|
||||
|
||||
get fullPublicSettings(): FullPublicSettings {
|
||||
return {
|
||||
...this.data.public,
|
||||
movie4kEnabled: this.data.radarr.some(
|
||||
(radarr) => radarr.is4k && radarr.isDefault
|
||||
),
|
||||
series4kEnabled: this.data.sonarr.some(
|
||||
(sonarr) => sonarr.is4k && sonarr.isDefault
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
get notifications(): NotificationSettings {
|
||||
return this.data.notifications;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,9 @@ const logger = winston.createLogger({
|
||||
),
|
||||
}),
|
||||
new winston.transports.DailyRotateFile({
|
||||
filename: path.join(__dirname, '../config/logs/overseerr-%DATE%.log'),
|
||||
filename: process.env.CONFIG_DIRECTORY
|
||||
? `${process.env.CONFIG_DIRECTORY}/logs/overseerr-%DATE%.log`
|
||||
: path.join(__dirname, '../config/logs/overseerr-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
zippedArchive: true,
|
||||
maxSize: '20m',
|
||||
|
||||
43
server/migration/1610070934506-LocalUsers.ts
Normal file
43
server/migration/1610070934506-LocalUsers.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class LocalUsers1610070934506 implements MigrationInterface {
|
||||
name = 'LocalUsers1610070934506';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "plexId" integer NOT NULL, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt" FROM "user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType" FROM "user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "plexId" integer NOT NULL, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType" FROM "temporary_user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user"`);
|
||||
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "plexId" integer NOT NULL, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt" FROM "temporary_user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user"`);
|
||||
}
|
||||
}
|
||||
91
server/migration/1610370640747-Add4kStatusFields.ts
Normal file
91
server/migration/1610370640747-Add4kStatusFields.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class Add4kStatusFields1610370640747 implements MigrationInterface {
|
||||
name = 'Add4kStatusFields1610370640747';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_season" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "status4k" integer NOT NULL DEFAULT (1), CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_season"("id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId" FROM "season"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "season"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_season" RENAME TO "season"`
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "media"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "media"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "media_request"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "media_request"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_media_request" RENAME TO "media_request"`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "media_request" RENAME TO "temporary_media_request"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "temporary_media_request"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_media_request"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
|
||||
await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "temporary_media"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "season" RENAME TO "temporary_season"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "season" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "season"("id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId" FROM "temporary_season"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_season"`);
|
||||
}
|
||||
}
|
||||
52
server/migration/1610522845513-AddMediaAddedFieldToMedia.ts
Normal file
52
server/migration/1610522845513-AddMediaAddedFieldToMedia.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddMediaAddedFieldToMedia1610522845513
|
||||
implements MigrationInterface {
|
||||
name = 'AddMediaAddedFieldToMedia1610522845513';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k" FROM "media"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "media"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
|
||||
await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k" FROM "temporary_media"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { isAuthenticated } from '../middleware/auth';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import logger from '../logger';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import { UserType } from '../constants/user';
|
||||
|
||||
const authRoutes = Router();
|
||||
|
||||
@@ -67,6 +68,7 @@ authRoutes.post('/login', async (req, res, next) => {
|
||||
plexToken: account.authToken,
|
||||
permissions: Permission.ADMIN,
|
||||
avatar: account.thumb,
|
||||
userType: UserType.PLEX,
|
||||
});
|
||||
await userRepository.save(user);
|
||||
}
|
||||
@@ -89,6 +91,7 @@ authRoutes.post('/login', async (req, res, next) => {
|
||||
plexToken: account.authToken,
|
||||
permissions: settings.main.defaultPermissions,
|
||||
avatar: account.thumb,
|
||||
userType: UserType.PLEX,
|
||||
});
|
||||
await userRepository.save(user);
|
||||
} else {
|
||||
@@ -126,6 +129,53 @@ authRoutes.post('/login', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.post('/local', async (req, res, next) => {
|
||||
const userRepository = getRepository(User);
|
||||
const body = req.body as { email?: string; password?: string };
|
||||
|
||||
if (!body.email || !body.password) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'You must provide an email and a password' });
|
||||
}
|
||||
try {
|
||||
const user = await userRepository.findOne({
|
||||
select: ['id', 'password'],
|
||||
where: { email: body.email, userType: UserType.LOCAL },
|
||||
});
|
||||
|
||||
const isCorrectCredentials = await user?.passwordMatch(body.password);
|
||||
|
||||
// User doesn't exist or credentials are incorrect
|
||||
if (!isCorrectCredentials) {
|
||||
logger.info('Failed login attempt from user with incorrect credentials', {
|
||||
label: 'Auth',
|
||||
account: {
|
||||
email: body.email,
|
||||
password: '__REDACTED__',
|
||||
},
|
||||
});
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You do not have access to this Plex server',
|
||||
});
|
||||
}
|
||||
|
||||
// Set logged in session
|
||||
if (user && req.session) {
|
||||
req.session.userId = user.id;
|
||||
}
|
||||
|
||||
return res.status(200).json(user?.filter() ?? {});
|
||||
} catch (e) {
|
||||
logger.error(e.message, { label: 'Auth' });
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Something went wrong.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.get('/logout', (req, res, next) => {
|
||||
req.session?.destroy((err) => {
|
||||
if (err) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import mediaRoutes from './media';
|
||||
import personRoutes from './person';
|
||||
import collectionRoutes from './collection';
|
||||
import { getAppVersion, getCommitTag } from '../utils/appVersion';
|
||||
import serviceRoutes from './service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -30,7 +31,7 @@ router.use('/user', isAuthenticated(Permission.MANAGE_USERS), user);
|
||||
router.get('/settings/public', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
return res.status(200).json(settings.public);
|
||||
return res.status(200).json(settings.fullPublicSettings);
|
||||
});
|
||||
router.use(
|
||||
'/settings',
|
||||
@@ -45,6 +46,7 @@ router.use('/tv', isAuthenticated(), tvRoutes);
|
||||
router.use('/media', isAuthenticated(), mediaRoutes);
|
||||
router.use('/person', isAuthenticated(), personRoutes);
|
||||
router.use('/collection', isAuthenticated(), collectionRoutes);
|
||||
router.use('/service', isAuthenticated(), serviceRoutes);
|
||||
router.use('/auth', authRoutes);
|
||||
|
||||
router.get('/', (_req, res) => {
|
||||
|
||||
@@ -47,6 +47,10 @@ mediaRoutes.get('/', async (req, res, next) => {
|
||||
updatedAt: 'DESC',
|
||||
};
|
||||
break;
|
||||
case 'mediaAdded':
|
||||
sortFilter = {
|
||||
mediaAddedAt: 'DESC',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -110,15 +110,21 @@ requestRoutes.post(
|
||||
media = new Media({
|
||||
tmdbId: tmdbMedia.id,
|
||||
tvdbId: tmdbMedia.external_ids.tvdb_id,
|
||||
status: MediaStatus.PENDING,
|
||||
status: !req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
|
||||
status4k: req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
|
||||
mediaType: req.body.mediaType,
|
||||
});
|
||||
await mediaRepository.save(media);
|
||||
} else {
|
||||
if (media.status === MediaStatus.UNKNOWN) {
|
||||
if (media.status === MediaStatus.UNKNOWN && !req.body.is4k) {
|
||||
media.status = MediaStatus.PENDING;
|
||||
await mediaRepository.save(media);
|
||||
}
|
||||
|
||||
if (media.status4k === MediaStatus.UNKNOWN && req.body.is4k) {
|
||||
media.status4k = MediaStatus.PENDING;
|
||||
await mediaRepository.save(media);
|
||||
}
|
||||
}
|
||||
|
||||
if (req.body.mediaType === 'movie') {
|
||||
@@ -137,6 +143,10 @@ requestRoutes.post(
|
||||
req.user?.hasPermission(Permission.AUTO_APPROVE_MOVIE)
|
||||
? req.user
|
||||
: undefined,
|
||||
is4k: req.body.is4k,
|
||||
serverId: req.body.serverId,
|
||||
profileId: req.body.profileId,
|
||||
rootFolder: req.body.rootFolder,
|
||||
});
|
||||
|
||||
await requestRepository.save(request);
|
||||
@@ -149,13 +159,15 @@ requestRoutes.post(
|
||||
// already requested. In the case they were, we just throw out any duplicates but still approve the request.
|
||||
// (Unless there are no seasons, in which case we abort)
|
||||
if (media.requests) {
|
||||
existingSeasons = media.requests.reduce((seasons, request) => {
|
||||
const combinedSeasons = request.seasons.map(
|
||||
(season) => season.seasonNumber
|
||||
);
|
||||
existingSeasons = media.requests
|
||||
.filter((request) => request.is4k === req.body.is4k)
|
||||
.reduce((seasons, request) => {
|
||||
const combinedSeasons = request.seasons.map(
|
||||
(season) => season.seasonNumber
|
||||
);
|
||||
|
||||
return [...seasons, ...combinedSeasons];
|
||||
}, [] as number[]);
|
||||
return [...seasons, ...combinedSeasons];
|
||||
}, [] as number[]);
|
||||
}
|
||||
|
||||
const finalSeasons = requestedSeasons.filter(
|
||||
@@ -186,6 +198,10 @@ requestRoutes.post(
|
||||
req.user?.hasPermission(Permission.AUTO_APPROVE_TV)
|
||||
? req.user
|
||||
: undefined,
|
||||
is4k: req.body.is4k,
|
||||
serverId: req.body.serverId,
|
||||
profileId: req.body.profileId,
|
||||
rootFolder: req.body.rootFolder,
|
||||
seasons: finalSeasons.map(
|
||||
(sn) =>
|
||||
new SeasonRequest({
|
||||
@@ -205,11 +221,31 @@ requestRoutes.post(
|
||||
|
||||
next({ status: 500, message: 'Invalid media type' });
|
||||
} catch (e) {
|
||||
next({ message: e.message, status: 500 });
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
requestRoutes.get('/count', async (_req, res, next) => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
try {
|
||||
const pendingCount = await requestRepository.count({
|
||||
status: MediaRequestStatus.PENDING,
|
||||
});
|
||||
const approvedCount = await requestRepository.count({
|
||||
status: MediaRequestStatus.APPROVED,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
pending: pendingCount,
|
||||
approved: approvedCount,
|
||||
});
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
requestRoutes.get('/:requestId', async (req, res, next) => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
@@ -225,6 +261,102 @@ requestRoutes.get('/:requestId', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
requestRoutes.put<{ requestId: string }>(
|
||||
'/:requestId',
|
||||
isAuthenticated(Permission.MANAGE_REQUESTS),
|
||||
async (req, res, next) => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
try {
|
||||
const request = await requestRepository.findOne(
|
||||
Number(req.params.requestId)
|
||||
);
|
||||
|
||||
if (!request) {
|
||||
return next({ status: 404, message: 'Request not found' });
|
||||
}
|
||||
|
||||
if (req.body.mediaType === 'movie') {
|
||||
request.serverId = req.body.serverId;
|
||||
request.profileId = req.body.profileId;
|
||||
request.rootFolder = req.body.rootFolder;
|
||||
|
||||
requestRepository.save(request);
|
||||
} else if (req.body.mediaType === 'tv') {
|
||||
const mediaRepository = getRepository(Media);
|
||||
request.serverId = req.body.serverId;
|
||||
request.profileId = req.body.profileId;
|
||||
request.rootFolder = req.body.rootFolder;
|
||||
|
||||
const requestedSeasons = req.body.seasons as number[] | undefined;
|
||||
|
||||
if (!requestedSeasons || requestedSeasons.length === 0) {
|
||||
throw new Error(
|
||||
'Missing seasons. If you want to cancel a tv request, use the DELETE method.'
|
||||
);
|
||||
}
|
||||
|
||||
// Get existing media so we can work with all the requests
|
||||
const media = await mediaRepository.findOneOrFail({
|
||||
where: { tmdbId: request.media.tmdbId, mediaType: MediaType.TV },
|
||||
relations: ['requests'],
|
||||
});
|
||||
|
||||
// Get all requested seasons that are not part of this request we are editing
|
||||
const existingSeasons = media.requests
|
||||
.filter((r) => r.is4k === request.is4k && r.id !== request.id)
|
||||
.reduce((seasons, r) => {
|
||||
const combinedSeasons = r.seasons.map(
|
||||
(season) => season.seasonNumber
|
||||
);
|
||||
|
||||
return [...seasons, ...combinedSeasons];
|
||||
}, [] as number[]);
|
||||
|
||||
const filteredSeasons = requestedSeasons.filter(
|
||||
(rs) => !existingSeasons.includes(rs)
|
||||
);
|
||||
|
||||
if (filteredSeasons.length === 0) {
|
||||
return next({
|
||||
status: 202,
|
||||
message: 'No seasons available to request',
|
||||
});
|
||||
}
|
||||
|
||||
const newSeasons = requestedSeasons.filter(
|
||||
(sn) => !request.seasons.map((s) => s.seasonNumber).includes(sn)
|
||||
);
|
||||
|
||||
request.seasons = request.seasons.filter((rs) =>
|
||||
filteredSeasons.includes(rs.seasonNumber)
|
||||
);
|
||||
|
||||
if (newSeasons.length > 0) {
|
||||
logger.debug('Adding new seasons to request', {
|
||||
label: 'Media Request',
|
||||
newSeasons,
|
||||
});
|
||||
request.seasons.push(
|
||||
...newSeasons.map(
|
||||
(ns) =>
|
||||
new SeasonRequest({
|
||||
seasonNumber: ns,
|
||||
status: MediaRequestStatus.PENDING,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await requestRepository.save(request);
|
||||
}
|
||||
|
||||
return res.status(200).json(request);
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
requestRoutes.delete('/:requestId', async (req, res, next) => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
@@ -280,6 +412,7 @@ requestRoutes.post<{
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
requestRoutes.get<{
|
||||
requestId: string;
|
||||
status: 'pending' | 'approve' | 'decline';
|
||||
|
||||
148
server/routes/service.ts
Normal file
148
server/routes/service.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Router } from 'express';
|
||||
import RadarrAPI from '../api/radarr';
|
||||
import SonarrAPI from '../api/sonarr';
|
||||
import {
|
||||
ServiceCommonServer,
|
||||
ServiceCommonServerWithDetails,
|
||||
} from '../interfaces/api/serviceInterfaces';
|
||||
import { getSettings } from '../lib/settings';
|
||||
|
||||
const serviceRoutes = Router();
|
||||
|
||||
serviceRoutes.get('/radarr', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const filteredRadarrServers: ServiceCommonServer[] = settings.radarr.map(
|
||||
(radarr) => ({
|
||||
id: radarr.id,
|
||||
name: radarr.name,
|
||||
is4k: radarr.is4k,
|
||||
isDefault: radarr.isDefault,
|
||||
activeDirectory: radarr.activeDirectory,
|
||||
activeProfileId: radarr.activeProfileId,
|
||||
})
|
||||
);
|
||||
|
||||
return res.status(200).json(filteredRadarrServers);
|
||||
});
|
||||
|
||||
serviceRoutes.get<{ radarrId: string }>(
|
||||
'/radarr/:radarrId',
|
||||
async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const radarrSettings = settings.radarr.find(
|
||||
(radarr) => radarr.id === Number(req.params.radarrId)
|
||||
);
|
||||
|
||||
if (!radarrSettings) {
|
||||
return next({
|
||||
status: 404,
|
||||
message: 'Radarr server with provided ID does not exist.',
|
||||
});
|
||||
}
|
||||
|
||||
const radarr = new RadarrAPI({
|
||||
apiKey: radarrSettings.apiKey,
|
||||
url: `${radarrSettings.useSsl ? 'https' : 'http'}://${
|
||||
radarrSettings.hostname
|
||||
}:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`,
|
||||
});
|
||||
|
||||
const profiles = await radarr.getProfiles();
|
||||
const rootFolders = await radarr.getRootFolders();
|
||||
|
||||
return res.status(200).json({
|
||||
server: {
|
||||
id: radarrSettings.id,
|
||||
name: radarrSettings.name,
|
||||
is4k: radarrSettings.is4k,
|
||||
isDefault: radarrSettings.isDefault,
|
||||
activeDirectory: radarrSettings.activeDirectory,
|
||||
activeProfileId: radarrSettings.activeProfileId,
|
||||
},
|
||||
profiles: profiles.map((profile) => ({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
})),
|
||||
rootFolders: rootFolders.map((folder) => ({
|
||||
id: folder.id,
|
||||
freeSpace: folder.freeSpace,
|
||||
path: folder.path,
|
||||
totalSpace: folder.totalSpace,
|
||||
})),
|
||||
} as ServiceCommonServerWithDetails);
|
||||
}
|
||||
);
|
||||
|
||||
serviceRoutes.get('/sonarr', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const filteredSonarrServers: ServiceCommonServer[] = settings.sonarr.map(
|
||||
(sonarr) => ({
|
||||
id: sonarr.id,
|
||||
name: sonarr.name,
|
||||
is4k: sonarr.is4k,
|
||||
isDefault: sonarr.isDefault,
|
||||
activeDirectory: sonarr.activeDirectory,
|
||||
activeProfileId: sonarr.activeProfileId,
|
||||
activeAnimeProfileId: sonarr.activeAnimeProfileId,
|
||||
activeAnimeDirectory: sonarr.activeAnimeDirectory,
|
||||
})
|
||||
);
|
||||
|
||||
return res.status(200).json(filteredSonarrServers);
|
||||
});
|
||||
|
||||
serviceRoutes.get<{ sonarrId: string }>(
|
||||
'/sonarr/:sonarrId',
|
||||
async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const sonarrSettings = settings.sonarr.find(
|
||||
(radarr) => radarr.id === Number(req.params.sonarrId)
|
||||
);
|
||||
|
||||
if (!sonarrSettings) {
|
||||
return next({
|
||||
status: 404,
|
||||
message: 'Radarr server with provided ID does not exist.',
|
||||
});
|
||||
}
|
||||
|
||||
const sonarr = new SonarrAPI({
|
||||
apiKey: sonarrSettings.apiKey,
|
||||
url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${
|
||||
sonarrSettings.hostname
|
||||
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`,
|
||||
});
|
||||
|
||||
const profiles = await sonarr.getProfiles();
|
||||
const rootFolders = await sonarr.getRootFolders();
|
||||
|
||||
return res.status(200).json({
|
||||
server: {
|
||||
id: sonarrSettings.id,
|
||||
name: sonarrSettings.name,
|
||||
is4k: sonarrSettings.is4k,
|
||||
isDefault: sonarrSettings.isDefault,
|
||||
activeDirectory: sonarrSettings.activeDirectory,
|
||||
activeProfileId: sonarrSettings.activeProfileId,
|
||||
activeAnimeProfileId: sonarrSettings.activeAnimeProfileId,
|
||||
activeAnimeDirectory: sonarrSettings.activeAnimeDirectory,
|
||||
},
|
||||
profiles: profiles.map((profile) => ({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
})),
|
||||
rootFolders: rootFolders.map((folder) => ({
|
||||
id: folder.id,
|
||||
freeSpace: folder.freeSpace,
|
||||
path: folder.path,
|
||||
totalSpace: folder.totalSpace,
|
||||
})),
|
||||
} as ServiceCommonServerWithDetails);
|
||||
}
|
||||
);
|
||||
|
||||
export default serviceRoutes;
|
||||
@@ -5,31 +5,28 @@ import {
|
||||
SonarrSettings,
|
||||
Library,
|
||||
MainSettings,
|
||||
} from '../lib/settings';
|
||||
} from '../../lib/settings';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { User } from '../entity/User';
|
||||
import PlexAPI from '../api/plexapi';
|
||||
import { jobPlexFullSync } from '../job/plexsync';
|
||||
import SonarrAPI from '../api/sonarr';
|
||||
import RadarrAPI from '../api/radarr';
|
||||
import logger from '../logger';
|
||||
import { scheduledJobs } from '../job/schedule';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import { isAuthenticated } from '../middleware/auth';
|
||||
import { User } from '../../entity/User';
|
||||
import PlexAPI from '../../api/plexapi';
|
||||
import { jobPlexFullSync } from '../../job/plexsync';
|
||||
import SonarrAPI from '../../api/sonarr';
|
||||
import RadarrAPI from '../../api/radarr';
|
||||
import logger from '../../logger';
|
||||
import { scheduledJobs } from '../../job/schedule';
|
||||
import { Permission } from '../../lib/permissions';
|
||||
import { isAuthenticated } from '../../middleware/auth';
|
||||
import { merge, omit } from 'lodash';
|
||||
import Media from '../entity/Media';
|
||||
import { MediaRequest } from '../entity/MediaRequest';
|
||||
import { getAppVersion } from '../utils/appVersion';
|
||||
import { SettingsAboutResponse } from '../interfaces/api/settingsInterfaces';
|
||||
import { Notification } from '../lib/notifications';
|
||||
import DiscordAgent from '../lib/notifications/agents/discord';
|
||||
import EmailAgent from '../lib/notifications/agents/email';
|
||||
import SlackAgent from '../lib/notifications/agents/slack';
|
||||
import TelegramAgent from '../lib/notifications/agents/telegram';
|
||||
import PushoverAgent from '../lib/notifications/agents/pushover';
|
||||
import Media from '../../entity/Media';
|
||||
import { MediaRequest } from '../../entity/MediaRequest';
|
||||
import { getAppVersion } from '../../utils/appVersion';
|
||||
import { SettingsAboutResponse } from '../../interfaces/api/settingsInterfaces';
|
||||
import notificationRoutes from './notifications';
|
||||
|
||||
const settingsRoutes = Router();
|
||||
|
||||
settingsRoutes.use('/notifications', notificationRoutes);
|
||||
|
||||
const filteredMainSettings = (
|
||||
user: User,
|
||||
main: MainSettings
|
||||
@@ -437,176 +434,6 @@ settingsRoutes.get(
|
||||
}
|
||||
);
|
||||
|
||||
settingsRoutes.get('/notifications/discord', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.discord);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/notifications/discord', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.discord = req.body;
|
||||
settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.discord);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/notifications/discord/test', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
});
|
||||
}
|
||||
|
||||
const discordAgent = new DiscordAgent(req.body);
|
||||
discordAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
settingsRoutes.get('/notifications/slack', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.slack);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/notifications/slack', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.slack = req.body;
|
||||
settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.slack);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/notifications/slack/test', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
});
|
||||
}
|
||||
|
||||
const slackAgent = new SlackAgent(req.body);
|
||||
slackAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
settingsRoutes.get('/notifications/telegram', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.telegram);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/notifications/telegram', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.telegram = req.body;
|
||||
settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.telegram);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/notifications/telegram/test', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
});
|
||||
}
|
||||
|
||||
const telegramAgent = new TelegramAgent(req.body);
|
||||
telegramAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
settingsRoutes.get('/notifications/pushover', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.pushover);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/notifications/pushover', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.pushover = req.body;
|
||||
settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.pushover);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/notifications/pushover/test', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
});
|
||||
}
|
||||
|
||||
const pushoverAgent = new PushoverAgent(req.body);
|
||||
pushoverAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
settingsRoutes.get('/notifications/email', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.email);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/notifications/email', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.email = req.body;
|
||||
settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.email);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/notifications/email/test', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
});
|
||||
}
|
||||
|
||||
const emailAgent = new EmailAgent(req.body);
|
||||
emailAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
settingsRoutes.get('/about', async (req, res) => {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const mediaRequestRepository = getRepository(MediaRequest);
|
||||
265
server/routes/settings/notifications.ts
Normal file
265
server/routes/settings/notifications.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { Router } from 'express';
|
||||
import { getSettings } from '../../lib/settings';
|
||||
import { Notification } from '../../lib/notifications';
|
||||
import DiscordAgent from '../../lib/notifications/agents/discord';
|
||||
import EmailAgent from '../../lib/notifications/agents/email';
|
||||
import SlackAgent from '../../lib/notifications/agents/slack';
|
||||
import TelegramAgent from '../../lib/notifications/agents/telegram';
|
||||
import PushoverAgent from '../../lib/notifications/agents/pushover';
|
||||
import WebhookAgent from '../../lib/notifications/agents/webhook';
|
||||
|
||||
const notificationRoutes = Router();
|
||||
|
||||
notificationRoutes.get('/discord', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.discord);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/discord', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.discord = req.body;
|
||||
settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.discord);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/discord/test', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
});
|
||||
}
|
||||
|
||||
const discordAgent = new DiscordAgent(req.body);
|
||||
discordAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
notificationRoutes.get('/slack', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.slack);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/slack', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.slack = req.body;
|
||||
settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.slack);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/slack/test', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
});
|
||||
}
|
||||
|
||||
const slackAgent = new SlackAgent(req.body);
|
||||
slackAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
notificationRoutes.get('/telegram', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.telegram);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/telegram', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.telegram = req.body;
|
||||
settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.telegram);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/telegram/test', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
});
|
||||
}
|
||||
|
||||
const telegramAgent = new TelegramAgent(req.body);
|
||||
telegramAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
notificationRoutes.get('/pushover', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.pushover);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/pushover', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.pushover = req.body;
|
||||
settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.pushover);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/pushover/test', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
});
|
||||
}
|
||||
|
||||
const pushoverAgent = new PushoverAgent(req.body);
|
||||
pushoverAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
notificationRoutes.get('/email', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.email);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/email', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.email = req.body;
|
||||
settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.email);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/email/test', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
});
|
||||
}
|
||||
|
||||
const emailAgent = new EmailAgent(req.body);
|
||||
emailAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
notificationRoutes.get('/webhook', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const webhookSettings = settings.notifications.agents.webhook;
|
||||
|
||||
const response: typeof webhookSettings = {
|
||||
enabled: webhookSettings.enabled,
|
||||
types: webhookSettings.types,
|
||||
options: {
|
||||
...webhookSettings.options,
|
||||
jsonPayload: JSON.parse(
|
||||
Buffer.from(webhookSettings.options.jsonPayload, 'base64').toString(
|
||||
'ascii'
|
||||
)
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/webhook', (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
try {
|
||||
JSON.parse(req.body.options.jsonPayload);
|
||||
|
||||
settings.notifications.agents.webhook = {
|
||||
enabled: req.body.enabled,
|
||||
types: req.body.types,
|
||||
options: {
|
||||
jsonPayload: Buffer.from(req.body.options.jsonPayload).toString(
|
||||
'base64'
|
||||
),
|
||||
webhookUrl: req.body.options.webhookUrl,
|
||||
authHeader: req.body.options.authHeader,
|
||||
},
|
||||
};
|
||||
settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.webhook);
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
notificationRoutes.post('/webhook/test', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(req.body.options.jsonPayload);
|
||||
|
||||
const testBody = {
|
||||
enabled: req.body.enabled,
|
||||
types: req.body.types,
|
||||
options: {
|
||||
jsonPayload: Buffer.from(req.body.options.jsonPayload).toString(
|
||||
'base64'
|
||||
),
|
||||
webhookUrl: req.body.options.webhookUrl,
|
||||
authHeader: req.body.options.authHeader,
|
||||
},
|
||||
};
|
||||
|
||||
const webhookAgent = new WebhookAgent(testBody);
|
||||
webhookAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default notificationRoutes;
|
||||
@@ -6,6 +6,8 @@ import { User } from '../entity/User';
|
||||
import { hasPermission, Permission } from '../lib/permissions';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import gravatarUrl from 'gravatar-url';
|
||||
import { UserType } from '../constants/user';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -19,13 +21,34 @@ router.get('/', async (_req, res) => {
|
||||
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const settings = getSettings().notifications.agents.email;
|
||||
|
||||
const body = req.body;
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
const passedExplicitPassword = body.password && body.password.length > 0;
|
||||
const avatar = gravatarUrl(body.email, { default: 'mm', size: 200 });
|
||||
|
||||
if (!passedExplicitPassword && !settings.enabled) {
|
||||
throw new Error('Email notifications must be enabled');
|
||||
}
|
||||
|
||||
const user = new User({
|
||||
email: req.body.email,
|
||||
permissions: req.body.permissions,
|
||||
avatar: body.avatar ?? avatar,
|
||||
username: body.username ?? body.email,
|
||||
email: body.email,
|
||||
password: body.password,
|
||||
permissions: Permission.REQUEST,
|
||||
plexToken: '',
|
||||
userType: UserType.LOCAL,
|
||||
});
|
||||
|
||||
if (passedExplicitPassword) {
|
||||
await user?.setPassword(body.password);
|
||||
} else {
|
||||
await user?.resetPassword();
|
||||
}
|
||||
|
||||
await userRepository.save(user);
|
||||
return res.status(201).json(user.filter());
|
||||
} catch (e) {
|
||||
@@ -179,6 +202,7 @@ router.post('/import-from-plex', async (req, res, next) => {
|
||||
plexId: parseInt(account.id),
|
||||
plexToken: '',
|
||||
avatar: account.thumb,
|
||||
userType: UserType.PLEX,
|
||||
});
|
||||
await userRepository.save(newUser);
|
||||
createdUsers.push(newUser);
|
||||
|
||||
@@ -8,12 +8,16 @@ import TheMovieDb from '../api/themoviedb';
|
||||
import { MediaStatus, MediaType } from '../constants/media';
|
||||
import Media from '../entity/Media';
|
||||
import { MediaRequest } from '../entity/MediaRequest';
|
||||
import Season from '../entity/Season';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
|
||||
@EventSubscriber()
|
||||
export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
private async notifyAvailableMovie(entity: Media) {
|
||||
if (entity.status === MediaStatus.AVAILABLE) {
|
||||
private async notifyAvailableMovie(entity: Media, dbEntity?: Media) {
|
||||
if (
|
||||
entity.status === MediaStatus.AVAILABLE &&
|
||||
dbEntity?.status !== MediaStatus.AVAILABLE
|
||||
) {
|
||||
if (entity.mediaType === MediaType.MOVIE) {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const relatedRequests = await requestRepository.find({
|
||||
@@ -39,10 +43,13 @@ export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
}
|
||||
|
||||
private async notifyAvailableSeries(entity: Media, dbEntity: Media) {
|
||||
const seasonRepository = getRepository(Season);
|
||||
const newAvailableSeasons = entity.seasons
|
||||
.filter((season) => season.status === MediaStatus.AVAILABLE)
|
||||
.map((season) => season.seasonNumber);
|
||||
const oldAvailableSeasons = dbEntity.seasons
|
||||
const oldSeasonIds = dbEntity.seasons.map((season) => season.id);
|
||||
const oldSeasons = await seasonRepository.findByIds(oldSeasonIds);
|
||||
const oldAvailableSeasons = oldSeasons
|
||||
.filter((season) => season.status === MediaStatus.AVAILABLE)
|
||||
.map((season) => season.seasonNumber);
|
||||
|
||||
@@ -96,11 +103,15 @@ export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
}
|
||||
|
||||
public beforeUpdate(event: UpdateEvent<Media>): void {
|
||||
if (!event.entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.entity.mediaType === MediaType.MOVIE &&
|
||||
event.entity.status === MediaStatus.AVAILABLE
|
||||
) {
|
||||
this.notifyAvailableMovie(event.entity);
|
||||
this.notifyAvailableMovie(event.entity, event.databaseEntity);
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
98
server/templates/email/password/html.pug
Normal file
98
server/templates/email/password/html.pug
Normal file
@@ -0,0 +1,98 @@
|
||||
doctype html
|
||||
head
|
||||
meta(charset='utf-8')
|
||||
meta(name='x-apple-disable-message-reformatting')
|
||||
meta(http-equiv='x-ua-compatible' content='ie=edge')
|
||||
meta(name='viewport' content='width=device-width, initial-scale=1')
|
||||
meta(name='format-detection' content='telephone=no, date=no, address=no, email=no')
|
||||
link(href='https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap' rel='stylesheet' media='screen')
|
||||
//if mso
|
||||
xml
|
||||
o:officedocumentsettings
|
||||
o:pixelsperinch 96
|
||||
style.
|
||||
td,
|
||||
th,
|
||||
div,
|
||||
p,
|
||||
a,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
mso-line-height-rule: exactly;
|
||||
}
|
||||
style.
|
||||
@media (max-width: 600px) {
|
||||
.sm-w-full {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
div(role='article' aria-roledescription='email' aria-label='' lang='en')
|
||||
table(style="\
|
||||
background-color: #f2f4f6;\
|
||||
font-family: 'Nunito Sans', -apple-system, 'Segoe UI', sans-serif;\
|
||||
width: 100%;\
|
||||
" width='100%' bgcolor='#f2f4f6' cellpadding='0' cellspacing='0' role='presentation')
|
||||
tr
|
||||
td(align='center')
|
||||
table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation')
|
||||
tr
|
||||
td(align='center' style='\
|
||||
font-size: 16px;\
|
||||
padding-top: 25px;\
|
||||
padding-bottom: 25px;\
|
||||
text-align: center;\
|
||||
')
|
||||
a(href=applicationUrl style='\
|
||||
text-shadow: 0 1px 0 #ffffff;\
|
||||
font-weight: 700;\
|
||||
font-size: 16px;\
|
||||
color: #a8aaaf;\
|
||||
text-decoration: none;\
|
||||
')
|
||||
| Overseerr
|
||||
tr
|
||||
td(style='width: 100%' width='100%')
|
||||
table.sm-w-full(align='center' style='\
|
||||
background-color: #ffffff;\
|
||||
margin-left: auto;\
|
||||
margin-right: auto;\
|
||||
width: 570px;\
|
||||
' width='570' bgcolor='#ffffff' cellpadding='0' cellspacing='0' role='presentation')
|
||||
tr
|
||||
td(style='padding: 45px')
|
||||
div(style='font-size: 16px; text-align: center; padding-bottom: 14px;')
|
||||
| Your new password is:
|
||||
div(style='font-size: 16px; text-align: center')
|
||||
| #{password}
|
||||
p(style='\
|
||||
font-size: 13px;\
|
||||
line-height: 24px;\
|
||||
margin-top: 6px;\
|
||||
margin-bottom: 20px;\
|
||||
color: #51545e;\
|
||||
')
|
||||
a(href=applicationUrl style='color: #3869d4') Open Overseerr
|
||||
tr
|
||||
td
|
||||
table.sm-w-full(align='center' style='\
|
||||
margin-left: auto;\
|
||||
margin-right: auto;\
|
||||
text-align: center;\
|
||||
width: 570px;\
|
||||
' width='570' cellpadding='0' cellspacing='0' role='presentation')
|
||||
tr
|
||||
td(align='center' style='font-size: 16px; padding: 45px')
|
||||
p(style='\
|
||||
font-size: 13px;\
|
||||
line-height: 24px;\
|
||||
margin-top: 6px;\
|
||||
margin-bottom: 20px;\
|
||||
text-align: center;\
|
||||
color: #a8aaaf;\
|
||||
')
|
||||
| Overseerr.
|
||||
1
server/templates/email/password/subject.pug
Normal file
1
server/templates/email/password/subject.pug
Normal file
@@ -0,0 +1 @@
|
||||
= `Password reset - Overseerr`
|
||||
54
server/utils/asyncLock.ts
Normal file
54
server/utils/asyncLock.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// whenever you need to run async code on tv show or movie that does "get existing" / "check if need to create new" / "save"
|
||||
// then you need to put all of that code in "await asyncLock.dispatch" callback based on media id
|
||||
// this will guarantee that only one part of code will run at the same for this media id to avoid code
|
||||
// trying to create two or more entries for same movie/tvshow (which would result in sqlite unique constraint failrue)
|
||||
|
||||
class AsyncLock {
|
||||
private locked: { [key: string]: boolean } = {};
|
||||
private ee = new EventEmitter();
|
||||
|
||||
constructor() {
|
||||
this.ee.setMaxListeners(0);
|
||||
}
|
||||
|
||||
private acquire = async (key: string) => {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.locked[key]) {
|
||||
this.locked[key] = true;
|
||||
return resolve(undefined);
|
||||
}
|
||||
|
||||
const nextAcquire = () => {
|
||||
if (!this.locked[key]) {
|
||||
this.locked[key] = true;
|
||||
this.ee.removeListener(key, nextAcquire);
|
||||
return resolve(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
this.ee.on(key, nextAcquire);
|
||||
});
|
||||
};
|
||||
|
||||
private release = (key: string): void => {
|
||||
delete this.locked[key];
|
||||
setImmediate(() => this.ee.emit(key));
|
||||
};
|
||||
|
||||
public dispatch = async (
|
||||
key: string | number,
|
||||
callback: () => Promise<void>
|
||||
) => {
|
||||
const skey = String(key);
|
||||
await this.acquire(skey);
|
||||
try {
|
||||
await callback();
|
||||
} finally {
|
||||
this.release(skey);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default AsyncLock;
|
||||
93
snap/snapcraft.yaml
Normal file
93
snap/snapcraft.yaml
Normal file
@@ -0,0 +1,93 @@
|
||||
name: overseerr
|
||||
adopt-info: overseerr
|
||||
license: MIT
|
||||
summary: Request management and media discovery tool for the Plex ecosystem.
|
||||
description: >
|
||||
Overseerr is a free and open source software application for managing requests for your media library.
|
||||
It integrates with your existing services such as Sonarr, Radarr and Plex!
|
||||
base: core18
|
||||
confinement: strict
|
||||
|
||||
parts:
|
||||
overseerr:
|
||||
plugin: nodejs
|
||||
nodejs-version: "12.18.4"
|
||||
nodejs-package-manager: "yarn"
|
||||
nodejs-yarn-version: v1.22.5
|
||||
build-packages:
|
||||
- git
|
||||
- on arm64:
|
||||
- build-essential
|
||||
- automake
|
||||
- python-gi
|
||||
- python-gi-dev
|
||||
- on armhf:
|
||||
- libatomic1
|
||||
- build-essential
|
||||
- automake
|
||||
- python-gi
|
||||
- python-gi-dev
|
||||
source: .
|
||||
override-pull: |
|
||||
snapcraftctl pull
|
||||
# Get information to determine snap grade and version
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
COMMIT=$(git rev-parse HEAD)
|
||||
COMMIT_SHORT=$(git rev-parse --short HEAD)
|
||||
VERSION='v'$(cat package.json | grep 'version' | head -1 | sed 's/.*"\(.*\)"\,/\1/')
|
||||
if [ "$VERSION" = "v0.1.0" ]; then
|
||||
SNAP_VERSION=$COMMIT_SHORT
|
||||
GRADE=devel
|
||||
else
|
||||
SNAP_VERSION=$VERSION
|
||||
GRADE=stable
|
||||
fi
|
||||
# Write COMMIT_TAG as it is needed durring the build process
|
||||
echo $COMMIT > commit.txt
|
||||
# Print debug info for build version
|
||||
echo "{\"commitShort\": \"$COMMIT_SHORT\", \
|
||||
\"version\": \"$VERSION\", \
|
||||
\"snapVersion\": \"$SNAP_VERSION\", \
|
||||
\"snapGrade\": \"$GRADE\", \
|
||||
\"branch\": \"$BRANCH\", \
|
||||
\"commit\": \"$COMMIT\"}"
|
||||
echo "{\"commitTag\": \"$COMMIT\"}" > committag.json
|
||||
# Set snap version and grade
|
||||
snapcraftctl set-version "$SNAP_VERSION"
|
||||
snapcraftctl set-grade "$GRADE"
|
||||
build-environment:
|
||||
- PATH: "$SNAPCRAFT_PART_BUILD/node_modules/.bin:$SNAPCRAFT_PART_BUILD/../npm/bin:$PATH"
|
||||
override-build: |
|
||||
set -e
|
||||
# Set COMMIT_TAG before the build begins
|
||||
export COMMIT_TAG=$(cat $SNAPCRAFT_PART_BUILD/commit.txt)
|
||||
snapcraftctl build
|
||||
yarn build
|
||||
# Copy files needed for staging
|
||||
cp $SNAPCRAFT_PART_BUILD/committag.json $SNAPCRAFT_PART_INSTALL/
|
||||
cp -R $SNAPCRAFT_PART_BUILD/.next $SNAPCRAFT_PART_INSTALL/
|
||||
cp -R $SNAPCRAFT_PART_BUILD/dist $SNAPCRAFT_PART_INSTALL/
|
||||
cp -R $SNAPCRAFT_PART_BUILD/node_modules $SNAPCRAFT_PART_INSTALL/
|
||||
# Remove .github and gitbook as it will fail snap lint
|
||||
rm -rf $SNAPCRAFT_PART_INSTALL/.github && rm $SNAPCRAFT_PART_INSTALL/.gitbook.yaml
|
||||
stage:
|
||||
[ .next, ./* ]
|
||||
prime:
|
||||
[ .next, ./* ]
|
||||
|
||||
apps:
|
||||
deamon:
|
||||
command: /bin/sh -c "cd $SNAP && node dist/index.js"
|
||||
daemon: simple
|
||||
restart-condition: on-failure
|
||||
restart-delay: 5s
|
||||
plugs:
|
||||
- home
|
||||
- network
|
||||
- network-bind
|
||||
environment:
|
||||
PATH: "$SNAP/usr/sbin:$SNAP/usr/bin:$SNAP/sbin:$SNAP/bin:$PATH"
|
||||
OVERSEERR_SNAP: "True"
|
||||
CONFIG_DIRECTORY: $SNAP_USER_COMMON
|
||||
LOG_LEVEL: "debug"
|
||||
NODE_ENV: "production"
|
||||
1
src/assets/bolt.svg
Normal file
1
src/assets/bolt.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd"></path></svg>
|
||||
|
After Width: | Height: | Size: 257 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="264" viewBox="0 0 264 264" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="132" cy="132" r="123" fill="#5A67D8" stroke="#7F9CF5" stroke-width="18"/>
|
||||
<path d="M42.608 143.864C42.608 125.72 47.312 110.936 56.72 99.512C66.704 87.128 80.384 80.936 97.76 80.936C110.336 80.936 119.792 84.008 126.128 90.152C132.464 96.2 135.632 105.656 135.632 118.52C135.632 138.104 130.64 153.608 120.656 165.032C110.768 176.264 97.52 181.88 80.912 181.88C67.952 181.88 58.304 178.664 51.968 172.232C45.728 165.704 42.608 156.248 42.608 143.864ZM80.48 114.056C76.16 131.336 74 146.792 74 160.424C74 163.688 74.528 166.376 75.584 168.488C76.736 170.6 78.752 171.656 81.632 171.656C84.608 171.656 87.008 170.984 88.832 169.64C90.656 168.2 92.288 165.896 93.728 162.728C96.032 157.544 98.24 149.48 100.352 138.536C102.56 127.496 103.76 119.72 103.952 115.208C104.144 110.6 104.24 106.568 104.24 103.112C104.24 99.656 103.712 96.824 102.656 94.616C101.6 92.408 99.632 91.304 96.752 91.304C93.968 91.304 91.616 92.168 89.696 93.896C86.144 97.16 83.072 103.88 80.48 114.056ZM198.842 112.472C200.282 109.592 201.002 106.808 201.002 104.12C201.002 101.432 200.858 99.368 200.57 97.928C200.282 96.392 199.802 95.048 199.13 93.896C197.69 91.4 195.53 90.152 192.65 90.152C189.098 90.152 185.882 91.448 183.002 94.04C179.93 96.728 178.394 100.184 178.394 104.408C178.394 107.096 179.306 109.496 181.13 111.608C183.05 113.624 185.45 115.592 188.33 117.512C191.21 119.432 194.282 121.352 197.546 123.272C200.81 125.192 203.882 127.304 206.762 129.608C213.386 134.888 216.698 141.08 216.698 148.184C216.698 152.984 215.402 157.448 212.81 161.576C210.314 165.608 206.954 169.112 202.73 172.088C193.514 178.616 182.81 181.88 170.618 181.88C160.73 181.88 153.242 180.296 148.154 177.128C143.066 173.864 140.522 169.784 140.522 164.888C140.522 156.152 143.93 150.68 150.746 148.472C152.666 147.8 155.066 147.464 157.946 147.464C160.922 147.464 164.09 148.088 167.45 149.336C165.914 153.272 165.146 157.016 165.146 160.568C165.146 168.248 167.882 172.088 173.354 172.088C176.906 172.088 180.122 170.792 183.002 168.2C185.978 165.608 187.466 162.872 187.466 159.992C187.466 157.016 186.506 154.472 184.586 152.36C182.762 150.248 180.458 148.376 177.674 146.744C174.89 145.016 171.866 143.336 168.602 141.704C165.434 140.072 162.458 138.104 159.674 135.8C153.146 130.52 149.882 123.656 149.882 115.208C149.882 109.736 151.226 104.888 153.914 100.664C156.602 96.344 160.106 92.744 164.426 89.864C173.066 84.008 182.666 81.08 193.226 81.08C203.882 81.08 211.754 82.664 216.842 85.832C222.026 89 224.618 93.272 224.618 98.648C224.618 103.352 222.794 107.192 219.146 110.168C215.978 112.664 212.474 113.912 208.634 113.912C204.794 113.912 201.53 113.432 198.842 112.472Z" fill="#F7FAFC"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB |
1
src/assets/useradd.svg
Normal file
1
src/assets/useradd.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg class="w-6 h-6" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"></path></svg>
|
||||
|
After Width: | Height: | Size: 291 B |
@@ -58,9 +58,9 @@ const Alert: React.FC<AlertProps> = ({ title, children, type }) => {
|
||||
<div className="flex">
|
||||
<div className={`flex-shrink-0 ${design.titleColor}`}>{design.svg}</div>
|
||||
<div className="ml-3">
|
||||
<h3 className={`text-sm font-medium ${design.titleColor}`}>
|
||||
<div className={`text-sm font-medium ${design.titleColor}`}>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<div className={`mt-2 text-sm ${design.textColor}`}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,10 +39,10 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
|
||||
useClickOutside(buttonRef, () => setIsOpen(false));
|
||||
|
||||
return (
|
||||
<span className="relative z-0 inline-flex rounded-md shadow-sm">
|
||||
<span className="relative z-0 inline-flex h-full rounded-md shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
className={`relative inline-flex items-center px-4 py-2 text-white bg-indigo-600 hover:bg-indigo-500 text-sm leading-5 font-medium hover:text-white focus:ring-indigo active:bg-indigo-700 focus:z-10 focus:outline-none focus:ring-blue transition ease-in-out duration-150 ${
|
||||
className={`relative inline-flex h-full items-center px-4 py-2 text-white bg-indigo-600 hover:bg-indigo-500 text-sm leading-5 font-medium hover:text-white focus:ring-indigo active:bg-indigo-700 focus:z-10 focus:outline-none focus:ring-blue transition ease-in-out duration-150 ${
|
||||
children ? 'rounded-l-md' : 'rounded-md'
|
||||
} ${className}`}
|
||||
ref={buttonRef}
|
||||
@@ -54,7 +54,7 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
|
||||
{children && (
|
||||
<button
|
||||
type="button"
|
||||
className="relative inline-flex items-center px-2 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out bg-indigo-700 border border-indigo-600 rounded-r-md hover:bg-indigo-500 focus:z-10 focus:outline-none active:bg-indigo-700 focus:ring-blue"
|
||||
className="relative inline-flex items-center h-full px-2 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out bg-indigo-700 border border-indigo-600 rounded-r-md hover:bg-indigo-500 focus:z-10 focus:outline-none active:bg-indigo-700 focus:ring-blue"
|
||||
aria-label="Expand"
|
||||
onClick={() => setIsOpen((state) => !state)}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,37 @@
|
||||
import React from 'react';
|
||||
|
||||
export const SmallLoadingSpinner: React.FC = () => {
|
||||
return (
|
||||
<div className="inset-0 flex items-center justify-center w-full h-full text-gray-200">
|
||||
<svg
|
||||
className="w-10 h-10"
|
||||
viewBox="0 0 38 38"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<g fill="none" fillRule="evenodd">
|
||||
<g transform="translate(1 1)" strokeWidth="2">
|
||||
<circle strokeOpacity=".5" cx="18" cy="18" r="18" />
|
||||
<path d="M36 18c0-9.94-8.06-18-18-18">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 18 18"
|
||||
to="360 18 18"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LoadingSpinner: React.FC = () => {
|
||||
return (
|
||||
<div className="h-64 inset-0 flex justify-center items-center text-gray-200">
|
||||
<div className="inset-0 flex items-center justify-center h-64 text-gray-200">
|
||||
<svg
|
||||
className="w-16 h-16"
|
||||
viewBox="0 0 38 38"
|
||||
|
||||
@@ -66,7 +66,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||
return ReactDOM.createPortal(
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className="fixed top-0 left-0 right-0 bottom-0 bg-gray-800 bg-opacity-50 w-full h-full z-50 flex justify-center items-center"
|
||||
className="fixed top-0 bottom-0 left-0 right-0 z-50 flex items-center justify-center w-full h-full bg-gray-800 bg-opacity-50"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
typeof onCancel === 'function' && backgroundClickable
|
||||
@@ -98,7 +98,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||
show={!loading}
|
||||
>
|
||||
<div
|
||||
className="inline-block align-bottom bg-gray-700 sm:rounded-lg px-4 pt-5 pb-4 text-left overflow-auto shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-3xl w-full max-h-full"
|
||||
className="inline-block w-full max-h-full px-4 pt-5 pb-4 overflow-auto text-left align-bottom transition-all transform bg-gray-700 shadow-xl sm:rounded-lg sm:my-8 sm:align-middle sm:max-w-3xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline"
|
||||
@@ -106,7 +106,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||
>
|
||||
<div className="sm:flex sm:items-center">
|
||||
{iconSvg && (
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-gray-600 text-white sm:mx-0 sm:h-10 sm:w-10">
|
||||
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto text-white bg-gray-600 rounded-full sm:mx-0 sm:h-10 sm:w-10">
|
||||
{iconSvg}
|
||||
</div>
|
||||
)}
|
||||
@@ -116,12 +116,12 @@ const Modal: React.FC<ModalProps> = ({
|
||||
}`}
|
||||
>
|
||||
{title && (
|
||||
<h3
|
||||
className="text-lg leading-6 font-medium text-white"
|
||||
<span
|
||||
className="text-lg font-medium leading-6 text-white"
|
||||
id="modal-headline"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,7 +131,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
{(onCancel || onOk || onSecondary || onTertiary) && (
|
||||
<div className="mt-5 sm:mt-4 flex justify-center sm:justify-start flex-row-reverse">
|
||||
<div className="flex flex-row-reverse justify-center mt-5 sm:mt-4 sm:justify-start">
|
||||
{typeof onOk === 'function' && (
|
||||
<Button
|
||||
buttonType={okButtonType}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import Transition from '../../Transition';
|
||||
import { useLockBodyScroll } from '../../../hooks/useLockBodyScroll';
|
||||
import useClickOutside from '../../../hooks/useClickOutside';
|
||||
|
||||
interface SlideOverProps {
|
||||
show?: boolean;
|
||||
@@ -21,9 +21,6 @@ const SlideOver: React.FC<SlideOverProps> = ({
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const slideoverRef = useRef(null);
|
||||
useLockBodyScroll(show);
|
||||
useClickOutside(slideoverRef, () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
@@ -44,8 +41,15 @@ const SlideOver: React.FC<SlideOverProps> = ({
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className={`z-50 fixed inset-0 overflow-hidden bg-opacity-50 bg-gray-800`}
|
||||
onClick={() => onClose()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<section className="absolute inset-y-0 right-0 flex max-w-full pl-10">
|
||||
@@ -59,7 +63,12 @@ const SlideOver: React.FC<SlideOverProps> = ({
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="translate-x-full"
|
||||
>
|
||||
<div className="w-screen max-w-md" ref={slideoverRef}>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className="w-screen max-w-md"
|
||||
ref={slideoverRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-col h-full overflow-y-scroll bg-gray-700 shadow-xl">
|
||||
<header className="px-4 py-6 space-y-1 bg-indigo-600">
|
||||
<div className="flex items-center justify-between space-x-3">
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React from 'react';
|
||||
import useSWR from 'swr';
|
||||
import type {
|
||||
MovieResult,
|
||||
TvResult,
|
||||
PersonResult,
|
||||
} from '../../../server/models/Search';
|
||||
import TitleCard from '../TitleCard';
|
||||
import PersonCard from '../PersonCard';
|
||||
import TmdbTitleCard from '../TitleCard/TmdbTitleCard';
|
||||
import Slider from '../Slider';
|
||||
import Link from 'next/link';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import type { MediaResultsResponse } from '../../../server/interfaces/api/mediaInterfaces';
|
||||
import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces';
|
||||
import RequestCard from '../RequestCard';
|
||||
import MediaSlider from '../MediaSlider';
|
||||
|
||||
const messages = defineMessages({
|
||||
recentrequests: 'Recent Requests',
|
||||
@@ -26,50 +19,11 @@ const messages = defineMessages({
|
||||
trending: 'Trending',
|
||||
});
|
||||
|
||||
interface MovieDiscoverResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: MovieResult[];
|
||||
}
|
||||
|
||||
interface TvDiscoverResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: TvResult[];
|
||||
}
|
||||
|
||||
interface MixedResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: (TvResult | MovieResult | PersonResult)[];
|
||||
}
|
||||
|
||||
const Discover: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data: movieData, error: movieError } = useSWR<MovieDiscoverResult>(
|
||||
`/api/v1/discover/movies?language=${locale}`
|
||||
);
|
||||
const { data: tvData, error: tvError } = useSWR<TvDiscoverResult>(
|
||||
`/api/v1/discover/tv?language=${locale}`
|
||||
);
|
||||
|
||||
const {
|
||||
data: movieUpcomingData,
|
||||
error: movieUpcomingError,
|
||||
} = useSWR<MovieDiscoverResult>(
|
||||
`/api/v1/discover/movies/upcoming?language=${locale}`
|
||||
);
|
||||
|
||||
const { data: trendingData, error: trendingError } = useSWR<MixedResult>(
|
||||
`/api/v1/discover/trending?language=${locale}`
|
||||
);
|
||||
|
||||
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>(
|
||||
'/api/v1/media?filter=available&take=20&sort=modified'
|
||||
'/api/v1/media?filter=available&take=20&sort=mediaAdded'
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -140,202 +94,29 @@ const Discover: React.FC = () => {
|
||||
placeholder={<RequestCard.Placeholder />}
|
||||
emptyMessage={intl.formatMessage(messages.nopending)}
|
||||
/>
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link href="/discover/movies/upcoming">
|
||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>
|
||||
<FormattedMessage {...messages.upcoming} />
|
||||
</span>
|
||||
<svg
|
||||
className="w-6 h-6 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
<MediaSlider
|
||||
sliderKey="upcoming"
|
||||
isLoading={!movieUpcomingData && !movieUpcomingError}
|
||||
isEmpty={false}
|
||||
items={movieUpcomingData?.results.map((title) => (
|
||||
<TitleCard
|
||||
key={`upcoming-movie-slider-${title.id}`}
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
title={title.title}
|
||||
userScore={title.voteAverage}
|
||||
year={title.releaseDate}
|
||||
mediaType={title.mediaType}
|
||||
/>
|
||||
))}
|
||||
title={intl.formatMessage(messages.upcoming)}
|
||||
linkUrl="/discover/movies/upcoming"
|
||||
url="/api/v1/discover/movies/upcoming"
|
||||
/>
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link href="/discover/trending">
|
||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>
|
||||
<FormattedMessage {...messages.trending} />
|
||||
</span>
|
||||
<svg
|
||||
className="w-6 h-6 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
<MediaSlider
|
||||
sliderKey="trending"
|
||||
isLoading={!trendingData && !trendingError}
|
||||
isEmpty={false}
|
||||
items={trendingData?.results.map((title) => {
|
||||
switch (title.mediaType) {
|
||||
case 'movie':
|
||||
return (
|
||||
<TitleCard
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
title={title.title}
|
||||
userScore={title.voteAverage}
|
||||
year={title.releaseDate}
|
||||
mediaType={title.mediaType}
|
||||
/>
|
||||
);
|
||||
case 'tv':
|
||||
return (
|
||||
<TitleCard
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
title={title.name}
|
||||
userScore={title.voteAverage}
|
||||
year={title.firstAirDate}
|
||||
mediaType={title.mediaType}
|
||||
/>
|
||||
);
|
||||
case 'person':
|
||||
return (
|
||||
<PersonCard
|
||||
personId={title.id}
|
||||
name={title.name}
|
||||
profilePath={title.profilePath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
title={intl.formatMessage(messages.trending)}
|
||||
url="/api/v1/discover/trending"
|
||||
linkUrl="/discover/trending"
|
||||
/>
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link href="/discover/movies">
|
||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>
|
||||
<FormattedMessage {...messages.popularmovies} />
|
||||
</span>
|
||||
<svg
|
||||
className="w-6 h-6 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="movies"
|
||||
isLoading={!movieData && !movieError}
|
||||
isEmpty={false}
|
||||
items={movieData?.results.map((title) => (
|
||||
<TitleCard
|
||||
key={`popular-movie-slider-${title.id}`}
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
title={title.title}
|
||||
userScore={title.voteAverage}
|
||||
year={title.releaseDate}
|
||||
mediaType={title.mediaType}
|
||||
/>
|
||||
))}
|
||||
<MediaSlider
|
||||
sliderKey="popular-movies"
|
||||
title={intl.formatMessage(messages.popularmovies)}
|
||||
url="/api/v1/discover/movies"
|
||||
linkUrl="/discover/movies"
|
||||
/>
|
||||
<div className="mt-4 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link href="/discover/tv">
|
||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>
|
||||
<FormattedMessage {...messages.populartv} />
|
||||
</span>
|
||||
<svg
|
||||
className="w-6 h-6 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="tv"
|
||||
isLoading={!tvData && !tvError}
|
||||
isEmpty={false}
|
||||
items={tvData?.results.map((title) => (
|
||||
<TitleCard
|
||||
key={`popular-tv-slider-${title.id}`}
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
title={title.name}
|
||||
userScore={title.voteAverage}
|
||||
year={title.firstAirDate}
|
||||
mediaType={title.mediaType}
|
||||
/>
|
||||
))}
|
||||
<MediaSlider
|
||||
sliderKey="popular-tv"
|
||||
title={intl.formatMessage(messages.populartv)}
|
||||
url="/api/v1/discover/tv"
|
||||
linkUrl="/discover/tv"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
35
src/components/JSONEditor/index.tsx
Normal file
35
src/components/JSONEditor/index.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React, { HTMLAttributes } from 'react';
|
||||
import AceEditor from 'react-ace';
|
||||
import 'ace-builds/src-noconflict/mode-json';
|
||||
import 'ace-builds/src-noconflict/theme-dracula';
|
||||
|
||||
interface JSONEditorProps extends HTMLAttributes<HTMLDivElement> {
|
||||
name: string;
|
||||
value: string;
|
||||
onUpdate: (value: string) => void;
|
||||
}
|
||||
|
||||
const JSONEditor: React.FC<JSONEditorProps> = ({
|
||||
name,
|
||||
value,
|
||||
onUpdate,
|
||||
onBlur,
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-full overflow-hidden rounded-md">
|
||||
<AceEditor
|
||||
mode="json"
|
||||
theme="dracula"
|
||||
onChange={onUpdate}
|
||||
name={name}
|
||||
editorProps={{ $blockScrolling: true }}
|
||||
value={value}
|
||||
onBlur={onBlur}
|
||||
height="300px"
|
||||
width="100%"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JSONEditor;
|
||||
@@ -23,7 +23,7 @@ const availableLanguages: AvailableLanguageObject = {
|
||||
},
|
||||
ja: {
|
||||
code: 'ja',
|
||||
display: '日本語',
|
||||
display: 'Japanese',
|
||||
},
|
||||
fr: {
|
||||
code: 'fr',
|
||||
@@ -65,6 +65,10 @@ const availableLanguages: AvailableLanguageObject = {
|
||||
code: 'sv',
|
||||
display: 'Swedish',
|
||||
},
|
||||
'zh-Hant': {
|
||||
code: 'zh-Hant',
|
||||
display: 'Chinese (Traditional)',
|
||||
},
|
||||
};
|
||||
|
||||
const LanguagePicker: React.FC = () => {
|
||||
@@ -105,20 +109,20 @@ const LanguagePicker: React.FC = () => {
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
className="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg"
|
||||
className="absolute right-0 w-48 mt-2 origin-top-right rounded-md shadow-lg"
|
||||
ref={dropdownRef}
|
||||
>
|
||||
<div className="py-2 px-2 rounded-md bg-gray-700 ring-1 ring-black ring-opacity-5">
|
||||
<div className="px-2 py-2 bg-gray-700 rounded-md ring-1 ring-black ring-opacity-5">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="language"
|
||||
className="block text-sm leading-5 font-medium text-gray-300 pb-2"
|
||||
className="block pb-2 text-sm font-medium leading-5 text-gray-300"
|
||||
>
|
||||
<FormattedMessage {...messages.changelanguage} />
|
||||
</label>
|
||||
<select
|
||||
id="language"
|
||||
className="mt-1 form-select block w-full pl-3 pr-10 py-2 text-base leading-6 text-white bg-gray-700 border-gray-600 focus:outline-none focus:ring-indigo focus:border-blue-800 sm:text-sm sm:leading-5"
|
||||
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white bg-gray-700 border-gray-600 form-select focus:outline-none focus:ring-indigo focus:border-blue-800 sm:text-sm sm:leading-5"
|
||||
onChange={(e) =>
|
||||
setLocale && setLocale(e.target.value as AvailableLocales)
|
||||
}
|
||||
|
||||
143
src/components/Login/LocalLogin.tsx
Normal file
143
src/components/Login/LocalLogin.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Button from '../Common/Button';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import axios from 'axios';
|
||||
|
||||
const messages = defineMessages({
|
||||
email: 'Email Address',
|
||||
password: 'Password',
|
||||
validationemailrequired: 'Not a valid email address',
|
||||
validationpasswordrequired: 'Password required',
|
||||
loginerror: 'Something went wrong when trying to sign in',
|
||||
loggingin: 'Logging in...',
|
||||
login: 'Login',
|
||||
goback: 'Go back',
|
||||
});
|
||||
|
||||
interface LocalLoginProps {
|
||||
goBack: () => void;
|
||||
revalidate: () => void;
|
||||
}
|
||||
|
||||
const LocalLogin: React.FC<LocalLoginProps> = ({ goBack, revalidate }) => {
|
||||
const intl = useIntl();
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
const LoginSchema = Yup.object().shape({
|
||||
email: Yup.string()
|
||||
.email()
|
||||
.required(intl.formatMessage(messages.validationemailrequired)),
|
||||
password: Yup.string().required(
|
||||
intl.formatMessage(messages.validationpasswordrequired)
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
email: '',
|
||||
password: '',
|
||||
}}
|
||||
validationSchema={LoginSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await axios.post('/api/v1/auth/local', {
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
});
|
||||
} catch (e) {
|
||||
setLoginError(intl.formatMessage(messages.loginerror));
|
||||
} finally {
|
||||
revalidate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, isSubmitting, isValid }) => {
|
||||
return (
|
||||
<>
|
||||
<Form>
|
||||
<div className="sm:border-t sm:border-gray-800">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block my-1 text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.email)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="email"
|
||||
name="email"
|
||||
type="text"
|
||||
placeholder="name@example.com"
|
||||
className="text-white flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && touched.email && (
|
||||
<div className="mt-2 text-red-500">{errors.email}</div>
|
||||
)}
|
||||
</div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block my-1 text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.password)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={intl.formatMessage(messages.password)}
|
||||
className="text-white flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
|
||||
/>
|
||||
</div>
|
||||
{errors.password && touched.password && (
|
||||
<div className="mt-2 text-red-500">{errors.password}</div>
|
||||
)}
|
||||
</div>
|
||||
{loginError && (
|
||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||
<div className="mt-2 text-red-500">{loginError}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-5 mt-8 border-t border-gray-700">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
type="reset"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
goBack();
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.goback)}
|
||||
</Button>
|
||||
</span>
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.loggingin)
|
||||
: intl.formatMessage(messages.login)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocalLogin;
|
||||
@@ -4,17 +4,22 @@ import { useUser } from '../../hooks/useUser';
|
||||
import axios from 'axios';
|
||||
import { useRouter } from 'next/dist/client/router';
|
||||
import ImageFader from '../Common/ImageFader';
|
||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import Transition from '../Transition';
|
||||
import LanguagePicker from '../Layout/LanguagePicker';
|
||||
import Button from '../Common/Button';
|
||||
import LocalLogin from './LocalLogin';
|
||||
|
||||
const messages = defineMessages({
|
||||
signinplex: 'Sign in to continue',
|
||||
signinwithoverseerr: 'Sign in with Overseerr',
|
||||
});
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const [error, setError] = useState('');
|
||||
const [isProcessing, setProcessing] = useState(false);
|
||||
const [localLogin, setLocalLogin] = useState(false);
|
||||
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
|
||||
const { user, revalidate } = useUser();
|
||||
const router = useRouter();
|
||||
@@ -80,42 +85,67 @@ const Login: React.FC = () => {
|
||||
className="px-4 py-8 bg-gray-800 bg-opacity-50 shadow sm:rounded-lg"
|
||||
style={{ backdropFilter: 'blur(5px)' }}
|
||||
>
|
||||
<Transition
|
||||
show={!!error}
|
||||
enter="opacity-0 transition duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="opacity-100 transition duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="p-4 mb-4 bg-red-600 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="w-5 h-5 text-red-300"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-300">{error}</h3>
|
||||
{!localLogin ? (
|
||||
<>
|
||||
<Transition
|
||||
show={!!error}
|
||||
enter="opacity-0 transition duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="opacity-100 transition duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="p-4 mb-4 bg-red-600 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="w-5 h-5 text-red-300"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-300">
|
||||
{error}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<div className="pb-4">
|
||||
<PlexLoginButton
|
||||
isProcessing={isProcessing}
|
||||
onAuthToken={(authToken) => setAuthToken(authToken)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<PlexLoginButton
|
||||
isProcessing={isProcessing}
|
||||
onAuthToken={(authToken) => setAuthToken(authToken)}
|
||||
/>
|
||||
<span className="block w-full rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
className="w-full"
|
||||
// type="button"
|
||||
onClick={() => {
|
||||
setLocalLogin(true);
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.signinwithoverseerr)}
|
||||
</Button>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<LocalLogin
|
||||
goBack={() => setLocalLogin(false)}
|
||||
revalidate={revalidate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
104
src/components/MediaSlider/ShowMoreCard/index.tsx
Normal file
104
src/components/MediaSlider/ShowMoreCard/index.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import Link from 'next/link';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
seemore: 'See More',
|
||||
});
|
||||
|
||||
interface ShowMoreCardProps {
|
||||
url: string;
|
||||
posters: (string | undefined)[];
|
||||
}
|
||||
|
||||
const ShowMoreCard: React.FC<ShowMoreCardProps> = ({ url, posters }) => {
|
||||
const intl = useIntl();
|
||||
const [isHovered, setHovered] = useState(false);
|
||||
return (
|
||||
<Link href={url}>
|
||||
<a
|
||||
className={'w-36 sm:w-36 md:w-44'}
|
||||
onMouseEnter={() => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setHovered(true);
|
||||
}
|
||||
}}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
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 ${
|
||||
isHovered ? 'bg-gray-500 scale-105' : 'bg-gray-600 scale-100'
|
||||
}`}
|
||||
>
|
||||
<div style={{ paddingBottom: '150%' }}>
|
||||
<div className="absolute inset-0 flex flex-col items-center w-full h-full p-2">
|
||||
<div className="relative z-10 flex flex-wrap items-center justify-center h-full opacity-30">
|
||||
{posters[0] && (
|
||||
<div className="w-1/2 p-1">
|
||||
<img
|
||||
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[0]}`}
|
||||
alt=""
|
||||
className="w-full rounded-md"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{posters[1] && (
|
||||
<div className="w-1/2 p-1">
|
||||
<img
|
||||
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[1]}`}
|
||||
alt=""
|
||||
className="w-full rounded-md"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{posters[2] && (
|
||||
<div className="w-1/2 p-1">
|
||||
<img
|
||||
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[2]}`}
|
||||
alt=""
|
||||
className="w-full rounded-md"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{posters[3] && (
|
||||
<div className="w-1/2 p-1">
|
||||
<img
|
||||
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[3]}`}
|
||||
alt=""
|
||||
className="w-full rounded-md"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center text-white">
|
||||
<svg
|
||||
className="w-14"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-8.707l-3-3a1 1 0 00-1.414 1.414L10.586 9H7a1 1 0 100 2h3.586l-1.293 1.293a1 1 0 101.414 1.414l3-3a1 1 0 000-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div className="mt-2 font-extrabold">
|
||||
{intl.formatMessage(messages.seemore)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShowMoreCard;
|
||||
153
src/components/MediaSlider/index.tsx
Normal file
153
src/components/MediaSlider/index.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import Link from 'next/link';
|
||||
import React, { useContext } from 'react';
|
||||
import { useSWRInfinite } from 'swr';
|
||||
import type {
|
||||
MovieResult,
|
||||
PersonResult,
|
||||
TvResult,
|
||||
} from '../../../server/models/Search';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import PersonCard from '../PersonCard';
|
||||
import Slider from '../Slider';
|
||||
import TitleCard from '../TitleCard';
|
||||
import ShowMoreCard from './ShowMoreCard';
|
||||
|
||||
interface MixedResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: (TvResult | MovieResult | PersonResult)[];
|
||||
}
|
||||
|
||||
interface MediaSliderProps {
|
||||
title: string;
|
||||
url: string;
|
||||
linkUrl?: string;
|
||||
sliderKey: string;
|
||||
hideWhenEmpty?: boolean;
|
||||
}
|
||||
|
||||
const MediaSlider: React.FC<MediaSliderProps> = ({
|
||||
title,
|
||||
url,
|
||||
linkUrl,
|
||||
sliderKey,
|
||||
hideWhenEmpty = false,
|
||||
}) => {
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error } = useSWRInfinite<MixedResult>(
|
||||
(pageIndex: number, previousPageData: MixedResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${url}?page=${pageIndex + 1}&language=${locale}`;
|
||||
},
|
||||
{
|
||||
initialSize: 2,
|
||||
}
|
||||
);
|
||||
|
||||
if (hideWhenEmpty && (data?.[0].results ?? []).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const titles = (data ?? []).reduce(
|
||||
(a, v) => [...a, ...v.results],
|
||||
[] as (MovieResult | TvResult | PersonResult)[]
|
||||
);
|
||||
|
||||
const finalTitles = titles.slice(0, 20).map((title) => {
|
||||
switch (title.mediaType) {
|
||||
case 'movie':
|
||||
return (
|
||||
<TitleCard
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
title={title.title}
|
||||
userScore={title.voteAverage}
|
||||
year={title.releaseDate}
|
||||
mediaType={title.mediaType}
|
||||
/>
|
||||
);
|
||||
case 'tv':
|
||||
return (
|
||||
<TitleCard
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
title={title.name}
|
||||
userScore={title.voteAverage}
|
||||
year={title.firstAirDate}
|
||||
mediaType={title.mediaType}
|
||||
/>
|
||||
);
|
||||
case 'person':
|
||||
return (
|
||||
<PersonCard
|
||||
personId={title.id}
|
||||
name={title.name}
|
||||
profilePath={title.profilePath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (linkUrl && titles.length > 20) {
|
||||
finalTitles.push(
|
||||
<ShowMoreCard
|
||||
url={linkUrl}
|
||||
posters={titles
|
||||
.slice(20, 24)
|
||||
.map((title) =>
|
||||
title.mediaType !== 'person' ? title.posterPath : undefined
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
{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">
|
||||
<span>{title}</span>
|
||||
<svg
|
||||
className="w-6 h-6 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="inline-flex items-center text-xl leading-7 text-gray-300 sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey={sliderKey}
|
||||
isLoading={!data && !error}
|
||||
isEmpty={false}
|
||||
items={finalTitles}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaSlider;
|
||||
@@ -10,20 +10,13 @@ import type { MovieDetails as MovieDetailsType } from '../../../server/models/Mo
|
||||
import useSWR from 'swr';
|
||||
import { useRouter } from 'next/router';
|
||||
import Button from '../Common/Button';
|
||||
import type { MovieResult } from '../../../server/models/Search';
|
||||
import Link from 'next/link';
|
||||
import Slider from '../Slider';
|
||||
import TitleCard from '../TitleCard';
|
||||
import PersonCard from '../PersonCard';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import { useUser, Permission } from '../../hooks/useUser';
|
||||
import {
|
||||
MediaStatus,
|
||||
MediaRequestStatus,
|
||||
} from '../../../server/constants/media';
|
||||
import RequestModal from '../RequestModal';
|
||||
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import axios from 'axios';
|
||||
import SlideOver from '../Common/SlideOver';
|
||||
import RequestBlock from '../RequestBlock';
|
||||
@@ -38,6 +31,8 @@ import Head from 'next/head';
|
||||
import ExternalLinkBlock from '../ExternalLinkBlock';
|
||||
import { sortCrewPriority } from '../../utils/creditHelpers';
|
||||
import StatusBadge from '../StatusBadge';
|
||||
import RequestButton from '../RequestButton';
|
||||
import MediaSlider from '../MediaSlider';
|
||||
|
||||
const messages = defineMessages({
|
||||
releasedate: 'Release Date',
|
||||
@@ -55,8 +50,6 @@ const messages = defineMessages({
|
||||
cancelrequest: 'Cancel Request',
|
||||
available: 'Available',
|
||||
unavailable: 'Unavailable',
|
||||
request: 'Request',
|
||||
viewrequest: 'View Request',
|
||||
pending: 'Pending',
|
||||
overviewunavailable: 'Overview unavailable',
|
||||
manageModalTitle: 'Manage Movie',
|
||||
@@ -76,19 +69,11 @@ interface MovieDetailsProps {
|
||||
movie?: MovieDetailsType;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: MovieResult[];
|
||||
}
|
||||
|
||||
const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
const { hasPermission } = useUser();
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const [showRequestModal, setShowRequestModal] = useState(false);
|
||||
const [showManager, setShowManager] = useState(false);
|
||||
const { data, error, revalidate } = useSWR<MovieDetailsType>(
|
||||
`/api/v1/movie/${router.query.movieId}?language=${locale}`,
|
||||
@@ -96,12 +81,6 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
initialData: movie,
|
||||
}
|
||||
);
|
||||
const { data: recommended, error: recommendedError } = useSWR<SearchResult>(
|
||||
`/api/v1/movie/${router.query.movieId}/recommendations?language=${locale}`
|
||||
);
|
||||
const { data: similar, error: similarError } = useSWR<SearchResult>(
|
||||
`/api/v1/movie/${router.query.movieId}/similar?language=${locale}`
|
||||
);
|
||||
const { data: ratingData } = useSWR<RTRating>(
|
||||
`/api/v1/movie/${router.query.movieId}/ratings`
|
||||
);
|
||||
@@ -118,25 +97,11 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
return <Error statusCode={404} />;
|
||||
}
|
||||
|
||||
const activeRequest = data?.mediaInfo?.requests?.find(
|
||||
(request) => request.status === MediaRequestStatus.PENDING
|
||||
);
|
||||
|
||||
const trailerUrl = data.relatedVideos
|
||||
?.filter((r) => r.type === 'Trailer')
|
||||
.sort((a, b) => a.size - b.size)
|
||||
.pop()?.url;
|
||||
|
||||
const modifyRequest = async (type: 'approve' | 'decline') => {
|
||||
const response = await axios.get(
|
||||
`/api/v1/request/${activeRequest?.id}/${type}`
|
||||
);
|
||||
|
||||
if (response) {
|
||||
revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMedia = async () => {
|
||||
if (data?.mediaInfo?.id) {
|
||||
await axios.delete(`/api/v1/media/${data?.mediaInfo?.id}`);
|
||||
@@ -155,16 +120,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
<Head>
|
||||
<title>{data.title} - Overseerr</title>
|
||||
</Head>
|
||||
<RequestModal
|
||||
tmdbId={data.id}
|
||||
show={showRequestModal}
|
||||
type="movie"
|
||||
onComplete={() => {
|
||||
revalidate();
|
||||
setShowRequestModal(false);
|
||||
}}
|
||||
onCancel={() => setShowRequestModal(false)}
|
||||
/>
|
||||
|
||||
<SlideOver
|
||||
show={showManager}
|
||||
title={intl.formatMessage(messages.manageModalTitle)}
|
||||
@@ -216,7 +172,14 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left">
|
||||
<div className="mb-2">
|
||||
<StatusBadge status={data.mediaInfo?.status} />
|
||||
{data.mediaInfo && data.mediaInfo.status !== MediaStatus.UNKNOWN && (
|
||||
<span className="mr-2">
|
||||
<StatusBadge status={data.mediaInfo?.status} />
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
<StatusBadge status={data.mediaInfo?.status4k} is4k />
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl lg:text-4xl">
|
||||
{data.title}{' '}
|
||||
@@ -235,9 +198,14 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
{data.genres.map((g) => g.name).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-end flex-shrink-0 mt-4 lg:mt-0">
|
||||
<div className="relative z-10 flex flex-wrap justify-center flex-shrink-0 mt-4 sm:justify-end sm:flex-nowrap lg:mt-0">
|
||||
{trailerUrl && (
|
||||
<a href={trailerUrl} target={'_blank'} rel="noreferrer">
|
||||
<a
|
||||
href={trailerUrl}
|
||||
target={'_blank'}
|
||||
rel="noreferrer"
|
||||
className="mb-3 sm:mb-0"
|
||||
>
|
||||
<Button buttonType="ghost">
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
@@ -263,125 +231,18 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{(!data.mediaInfo ||
|
||||
data.mediaInfo?.status === MediaStatus.UNKNOWN) && (
|
||||
<Button
|
||||
buttonType="primary"
|
||||
className="ml-2"
|
||||
onClick={() => setShowRequestModal(true)}
|
||||
>
|
||||
{activeRequest ? (
|
||||
<svg
|
||||
className="w-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-5 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<FormattedMessage {...messages.request} />
|
||||
</Button>
|
||||
)}
|
||||
{activeRequest && (
|
||||
<ButtonWithDropdown
|
||||
dropdownIcon={
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
text={
|
||||
<>
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedMessage {...messages.viewrequest} />
|
||||
</>
|
||||
}
|
||||
onClick={() => setShowRequestModal(true)}
|
||||
className="ml-2"
|
||||
>
|
||||
{hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<>
|
||||
<ButtonWithDropdown.Item
|
||||
onClick={() => modifyRequest('approve')}
|
||||
>
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{intl.formatMessage(messages.approve)}
|
||||
</ButtonWithDropdown.Item>
|
||||
<ButtonWithDropdown.Item
|
||||
onClick={() => modifyRequest('decline')}
|
||||
>
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{intl.formatMessage(messages.decline)}
|
||||
</ButtonWithDropdown.Item>
|
||||
</>
|
||||
)}
|
||||
</ButtonWithDropdown>
|
||||
)}
|
||||
<div className="mb-3 sm:mb-0">
|
||||
<RequestButton
|
||||
mediaType="movie"
|
||||
media={data.mediaInfo}
|
||||
tmdbId={data.id}
|
||||
onUpdate={() => revalidate()}
|
||||
/>
|
||||
</div>
|
||||
{hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<Button
|
||||
buttonType="default"
|
||||
className="ml-2 first:ml-0"
|
||||
className="mb-3 ml-2 first:ml-0 sm:mb-0"
|
||||
onClick={() => setShowManager(true)}
|
||||
>
|
||||
<svg
|
||||
@@ -464,7 +325,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
<Link href={`/collection/${data.collection.id}`}>
|
||||
<a>
|
||||
<div
|
||||
className="relative transition duration-300 transform scale-100 bg-gray-800 bg-center bg-cover rounded-lg shadow-md cursor-pointer group hover:scale-105"
|
||||
className="relative z-0 transition duration-300 scale-100 bg-gray-800 bg-center bg-cover rounded-lg shadow-md cursor-pointer transform-gpu group hover:scale-105"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(180deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 0.80) 100%), url(//image.tmdb.org/t/p/w1440_and_h320_multi_faces/${data.collection.backdropPath})`,
|
||||
}}
|
||||
@@ -650,106 +511,20 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
{(recommended?.results ?? []).length > 0 && (
|
||||
<>
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
href="/movie/[movieId]/recommendations"
|
||||
as={`/movie/${data.id}/recommendations`}
|
||||
>
|
||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>
|
||||
<FormattedMessage {...messages.recommendations} />
|
||||
</span>
|
||||
<svg
|
||||
className="w-6 h-6 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="recommendations"
|
||||
isLoading={!recommended && !recommendedError}
|
||||
isEmpty={false}
|
||||
items={recommended?.results.map((title) => (
|
||||
<TitleCard
|
||||
key={`recommended-${title.id}`}
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
title={title.title}
|
||||
userScore={title.voteAverage}
|
||||
year={title.releaseDate}
|
||||
mediaType={title.mediaType}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(similar?.results ?? []).length > 0 && (
|
||||
<>
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
href="/movie/[movieId]/similar"
|
||||
as={`/movie/${data.id}/similar`}
|
||||
>
|
||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>
|
||||
<FormattedMessage {...messages.similar} />
|
||||
</span>
|
||||
<svg
|
||||
className="w-6 h-6 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="similar"
|
||||
isLoading={!similar && !similarError}
|
||||
isEmpty={false}
|
||||
items={similar?.results.map((title) => (
|
||||
<TitleCard
|
||||
key={`recommended-${title.id}`}
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
title={title.title}
|
||||
userScore={title.voteAverage}
|
||||
year={title.releaseDate}
|
||||
mediaType={title.mediaType}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<MediaSlider
|
||||
sliderKey="recommendations"
|
||||
title={intl.formatMessage(messages.recommendations)}
|
||||
url={`/api/v1/movie/${router.query.movieId}/recommendations`}
|
||||
linkUrl={`/movie/${data.id}/recommendations`}
|
||||
hideWhenEmpty
|
||||
/>
|
||||
<MediaSlider
|
||||
sliderKey="similar"
|
||||
title={intl.formatMessage(messages.similar)}
|
||||
url={`/api/v1/movie/${router.query.movieId}/similar`}
|
||||
linkUrl={`/movie/${data.id}/similar`}
|
||||
hideWhenEmpty
|
||||
/>
|
||||
<div className="pb-8" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,8 @@ const messages = defineMessages({
|
||||
mediafailed: 'Media Failed',
|
||||
mediafailedDescription:
|
||||
'Sends a notification when media fails to be added to services (Radarr/Sonarr). For certain agents, this will only send the notification to admins or users with the "Manage Requests" permission.',
|
||||
mediadeclined: 'Media Declined',
|
||||
mediadeclinedDescription: 'Sends a notification when a request is declined.',
|
||||
});
|
||||
|
||||
export const hasNotificationType = (
|
||||
@@ -41,6 +43,7 @@ export enum Notification {
|
||||
MEDIA_AVAILABLE = 8,
|
||||
MEDIA_FAILED = 16,
|
||||
TEST_NOTIFICATION = 32,
|
||||
MEDIA_DECLINED = 64,
|
||||
}
|
||||
|
||||
export interface NotificationItem {
|
||||
@@ -75,6 +78,12 @@ const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
|
||||
description: intl.formatMessage(messages.mediaapprovedDescription),
|
||||
value: Notification.MEDIA_APPROVED,
|
||||
},
|
||||
{
|
||||
id: 'media-declined',
|
||||
name: intl.formatMessage(messages.mediadeclined),
|
||||
description: intl.formatMessage(messages.mediadeclinedDescription),
|
||||
value: Notification.MEDIA_DECLINED,
|
||||
},
|
||||
{
|
||||
id: 'media-available',
|
||||
name: intl.formatMessage(messages.mediaavailable),
|
||||
|
||||
@@ -59,7 +59,7 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
|
||||
!hasPermission(Permission.MANAGE_SETTINGS, user.permissions) &&
|
||||
option.permission === Permission.MANAGE_SETTINGS)
|
||||
}
|
||||
onClick={() => {
|
||||
onChange={() => {
|
||||
onUpdate(
|
||||
hasPermission(option.permission, currentPermission)
|
||||
? currentPermission - option.permission
|
||||
|
||||
@@ -6,9 +6,15 @@ import { MediaRequestStatus } from '../../../server/constants/media';
|
||||
import Button from '../Common/Button';
|
||||
import axios from 'axios';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import RequestModal from '../RequestModal';
|
||||
import useRequestOverride from '../../hooks/useRequestOverride';
|
||||
|
||||
const messages = defineMessages({
|
||||
seasons: 'Seasons',
|
||||
requestoverrides: 'Request Overrides',
|
||||
server: 'Server',
|
||||
profilechanged: 'Profile Changed',
|
||||
rootfolder: 'Root Folder',
|
||||
});
|
||||
|
||||
interface RequestBlockProps {
|
||||
@@ -19,6 +25,8 @@ interface RequestBlockProps {
|
||||
const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
const intl = useIntl();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const { profile, rootFolder, server } = useRequestOverride(request);
|
||||
|
||||
const updateRequest = async (type: 'approve' | 'decline'): Promise<void> => {
|
||||
setIsUpdating(true);
|
||||
@@ -43,10 +51,24 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
|
||||
return (
|
||||
<div className="block">
|
||||
<RequestModal
|
||||
show={showEditModal}
|
||||
tmdbId={request.media.tmdbId}
|
||||
type={request.type}
|
||||
is4k={request.is4k}
|
||||
editRequest={request}
|
||||
onCancel={() => setShowEditModal(false)}
|
||||
onComplete={() => {
|
||||
if (onUpdate) {
|
||||
onUpdate();
|
||||
}
|
||||
setShowEditModal(false);
|
||||
}}
|
||||
/>
|
||||
<div className="px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="mr-6 flex-col items-center text-sm leading-5 text-gray-300 flex-1 min-w-0">
|
||||
<div className="flex flex-nowrap mb-1 white">
|
||||
<div className="flex-col items-center flex-1 min-w-0 mr-6 text-sm leading-5 text-gray-300">
|
||||
<div className="flex mb-1 flex-nowrap white">
|
||||
<svg
|
||||
className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
|
||||
fill="currentColor"
|
||||
@@ -59,7 +81,7 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="truncate w-40 md:w-auto">
|
||||
<span className="w-40 truncate md:w-auto">
|
||||
{request.requestedBy.username}
|
||||
</span>
|
||||
</div>
|
||||
@@ -78,13 +100,13 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="truncate w-40 md:w-auto">
|
||||
<span className="w-40 truncate md:w-auto">
|
||||
{request.modifiedBy?.username}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-2 flex-shrink-0 flex flex-wrap">
|
||||
<div className="flex flex-wrap flex-shrink-0 ml-2">
|
||||
{request.status === MediaRequestStatus.PENDING && (
|
||||
<>
|
||||
<span className="mr-1">
|
||||
@@ -107,7 +129,7 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
</svg>
|
||||
</Button>
|
||||
</span>
|
||||
<span>
|
||||
<span className="mr-1">
|
||||
<Button
|
||||
buttonType="danger"
|
||||
onClick={() => updateRequest('decline')}
|
||||
@@ -127,6 +149,22 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
</svg>
|
||||
</Button>
|
||||
</span>
|
||||
<span>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
onClick={() => setShowEditModal(true)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
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" />
|
||||
</svg>
|
||||
</Button>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{request.status !== MediaRequestStatus.PENDING && (
|
||||
@@ -153,11 +191,11 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
</div>
|
||||
<div className="mt-2 sm:flex sm:justify-between">
|
||||
<div className="sm:flex">
|
||||
<div className="mr-6 flex items-center text-sm leading-5 text-gray-300">
|
||||
{request.status === MediaRequestStatus.AVAILABLE && (
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(globalMessages.available)}
|
||||
</Badge>
|
||||
<div className="flex items-center mr-6 text-sm leading-5 text-gray-300">
|
||||
{request.is4k && (
|
||||
<span className="mr-1">
|
||||
<Badge badgeType="warning">4K</Badge>
|
||||
</span>
|
||||
)}
|
||||
{request.status === MediaRequestStatus.APPROVED && (
|
||||
<Badge badgeType="success">
|
||||
@@ -176,7 +214,7 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center text-sm leading-5 text-gray-300 sm:mt-0">
|
||||
<div className="flex items-center mt-2 text-sm leading-5 text-gray-300 sm:mt-0">
|
||||
<svg
|
||||
className="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -195,13 +233,13 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
</div>
|
||||
</div>
|
||||
{(request.seasons ?? []).length > 0 && (
|
||||
<div className="mt-2 text-sm flex flex-col">
|
||||
<div className="flex flex-col mt-2 text-sm">
|
||||
<div className="mb-2">{intl.formatMessage(messages.seasons)}</div>
|
||||
<div>
|
||||
{request.seasons.map((season) => (
|
||||
<span
|
||||
key={`season-${season.id}`}
|
||||
className="mr-2 mb-1 inline-block"
|
||||
className="inline-block mb-1 mr-2"
|
||||
>
|
||||
<Badge>{season.seasonNumber}</Badge>
|
||||
</span>
|
||||
@@ -209,6 +247,39 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(server || profile || rootFolder) && (
|
||||
<>
|
||||
<div className="mt-4 mb-1 text-sm">
|
||||
{intl.formatMessage(messages.requestoverrides)}
|
||||
</div>
|
||||
<ul className="px-2 text-xs bg-gray-800 divide-y divide-gray-700 rounded-md">
|
||||
{server && (
|
||||
<li className="flex justify-between px-1 py-2">
|
||||
<span className="font-bold">
|
||||
{intl.formatMessage(messages.server)}
|
||||
</span>
|
||||
<span>{server}</span>
|
||||
</li>
|
||||
)}
|
||||
{profile !== null && (
|
||||
<li className="flex justify-between px-1 py-2">
|
||||
<span className="font-bold">
|
||||
{intl.formatMessage(messages.profilechanged)}
|
||||
</span>
|
||||
<span>ID {profile}</span>
|
||||
</li>
|
||||
)}
|
||||
{rootFolder && (
|
||||
<li className="flex justify-between px-1 py-2">
|
||||
<span className="mr-2 font-bold">
|
||||
{intl.formatMessage(messages.rootfolder)}
|
||||
</span>
|
||||
<span>{rootFolder}</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
563
src/components/RequestButton/index.tsx
Normal file
563
src/components/RequestButton/index.tsx
Normal file
@@ -0,0 +1,563 @@
|
||||
import axios from 'axios';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
} from '../../../server/constants/media';
|
||||
import Media from '../../../server/entity/Media';
|
||||
import { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
import { SettingsContext } from '../../context/SettingsContext';
|
||||
import { Permission, useUser } from '../../hooks/useUser';
|
||||
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
|
||||
import RequestModal from '../RequestModal';
|
||||
|
||||
const messages = defineMessages({
|
||||
viewrequest: 'View Request',
|
||||
viewrequest4k: 'View 4K Request',
|
||||
request: 'Request',
|
||||
request4k: 'Request 4K',
|
||||
requestmore: 'Request More',
|
||||
requestmore4k: 'Request More 4K',
|
||||
approverequest: 'Approve Request',
|
||||
approverequest4k: 'Approve 4K Request',
|
||||
declinerequest: 'Decline Request',
|
||||
declinerequest4k: 'Decline 4K Request',
|
||||
approverequests:
|
||||
'Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}',
|
||||
declinerequests:
|
||||
'Decline {requestCount} {requestCount, plural, one {Request} other {Requests}}',
|
||||
approve4krequests:
|
||||
'Approve {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}',
|
||||
decline4krequests:
|
||||
'Decline {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}',
|
||||
});
|
||||
|
||||
interface ButtonOption {
|
||||
id: string;
|
||||
text: string;
|
||||
action: () => void;
|
||||
svg?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface RequestButtonProps {
|
||||
mediaType: 'movie' | 'tv';
|
||||
onUpdate: () => void;
|
||||
tmdbId: number;
|
||||
media?: Media;
|
||||
isShowComplete?: boolean;
|
||||
is4kShowComplete?: boolean;
|
||||
}
|
||||
|
||||
const RequestButton: React.FC<RequestButtonProps> = ({
|
||||
tmdbId,
|
||||
onUpdate,
|
||||
media,
|
||||
mediaType,
|
||||
isShowComplete = false,
|
||||
is4kShowComplete = false,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const settings = useContext(SettingsContext);
|
||||
const { hasPermission } = useUser();
|
||||
const [showRequestModal, setShowRequestModal] = useState(false);
|
||||
const [showRequest4kModal, setShowRequest4kModal] = useState(false);
|
||||
|
||||
const activeRequest = media?.requests.find(
|
||||
(request) => request.status === MediaRequestStatus.PENDING && !request.is4k
|
||||
);
|
||||
const active4kRequest = media?.requests.find(
|
||||
(request) => request.status === MediaRequestStatus.PENDING && request.is4k
|
||||
);
|
||||
|
||||
// All pending
|
||||
const activeRequests = media?.requests.filter(
|
||||
(request) => request.status === MediaRequestStatus.PENDING && !request.is4k
|
||||
);
|
||||
|
||||
const active4kRequests = media?.requests.filter(
|
||||
(request) => request.status === MediaRequestStatus.PENDING && request.is4k
|
||||
);
|
||||
|
||||
const modifyRequest = async (
|
||||
request: MediaRequest,
|
||||
type: 'approve' | 'decline'
|
||||
) => {
|
||||
const response = await axios.get(`/api/v1/request/${request.id}/${type}`);
|
||||
|
||||
if (response) {
|
||||
onUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
const modifyRequests = async (
|
||||
requests: MediaRequest[],
|
||||
type: 'approve' | 'decline'
|
||||
): Promise<void> => {
|
||||
if (!requests) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
requests.map(async (request) => {
|
||||
return axios.get(`/api/v1/request/${request.id}/${type}`);
|
||||
})
|
||||
);
|
||||
|
||||
onUpdate();
|
||||
};
|
||||
|
||||
const buttons: ButtonOption[] = [];
|
||||
if (
|
||||
(!media || media.status === MediaStatus.UNKNOWN) &&
|
||||
hasPermission(Permission.REQUEST)
|
||||
) {
|
||||
buttons.push({
|
||||
id: 'request',
|
||||
text: intl.formatMessage(messages.request),
|
||||
action: () => {
|
||||
setShowRequestModal(true);
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
hasPermission(Permission.REQUEST) &&
|
||||
mediaType === 'tv' &&
|
||||
media &&
|
||||
media.status !== MediaStatus.AVAILABLE &&
|
||||
media.status !== MediaStatus.UNKNOWN &&
|
||||
!isShowComplete
|
||||
) {
|
||||
buttons.push({
|
||||
id: 'request-more',
|
||||
text: intl.formatMessage(messages.requestmore),
|
||||
action: () => {
|
||||
setShowRequestModal(true);
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(!media || media.status4k === MediaStatus.UNKNOWN) &&
|
||||
(hasPermission(Permission.REQUEST_4K) ||
|
||||
(mediaType === 'movie' && hasPermission(Permission.REQUEST_4K_MOVIE)) ||
|
||||
(mediaType === 'tv' && hasPermission(Permission.REQUEST_4K_TV))) &&
|
||||
((settings.currentSettings.movie4kEnabled && mediaType === 'movie') ||
|
||||
(settings.currentSettings.series4kEnabled && mediaType === 'tv'))
|
||||
) {
|
||||
buttons.push({
|
||||
id: 'request4k',
|
||||
text: intl.formatMessage(messages.request4k),
|
||||
action: () => {
|
||||
setShowRequest4kModal(true);
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
mediaType === 'tv' &&
|
||||
(hasPermission(Permission.REQUEST_4K) ||
|
||||
(mediaType === 'tv' && hasPermission(Permission.REQUEST_4K_TV))) &&
|
||||
media &&
|
||||
media.status4k !== MediaStatus.AVAILABLE &&
|
||||
media.status4k !== MediaStatus.UNKNOWN &&
|
||||
!is4kShowComplete &&
|
||||
settings.currentSettings.series4kEnabled
|
||||
) {
|
||||
buttons.push({
|
||||
id: 'request-more-4k',
|
||||
text: intl.formatMessage(messages.requestmore4k),
|
||||
action: () => {
|
||||
setShowRequest4kModal(true);
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
activeRequest &&
|
||||
mediaType === 'movie' &&
|
||||
hasPermission(Permission.REQUEST)
|
||||
) {
|
||||
buttons.push({
|
||||
id: 'active-request',
|
||||
text: intl.formatMessage(messages.viewrequest),
|
||||
action: () => setShowRequestModal(true),
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
active4kRequest &&
|
||||
mediaType === 'movie' &&
|
||||
(hasPermission(Permission.REQUEST_4K) ||
|
||||
hasPermission(Permission.REQUEST_4K_MOVIE))
|
||||
) {
|
||||
buttons.push({
|
||||
id: 'active-4k-request',
|
||||
text: intl.formatMessage(messages.viewrequest4k),
|
||||
action: () => setShowRequest4kModal(true),
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
activeRequest &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
mediaType === 'movie'
|
||||
) {
|
||||
buttons.push(
|
||||
{
|
||||
id: 'approve-request',
|
||||
text: intl.formatMessage(messages.approverequest),
|
||||
action: () => {
|
||||
modifyRequest(activeRequest, 'approve');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'decline-request',
|
||||
text: intl.formatMessage(messages.declinerequest),
|
||||
action: () => {
|
||||
modifyRequest(activeRequest, 'decline');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
activeRequests &&
|
||||
activeRequests.length > 0 &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
mediaType === 'tv'
|
||||
) {
|
||||
buttons.push(
|
||||
{
|
||||
id: 'approve-request-batch',
|
||||
text: intl.formatMessage(messages.approverequests, {
|
||||
requestCount: activeRequests.length,
|
||||
}),
|
||||
action: () => {
|
||||
modifyRequests(activeRequests, 'approve');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'decline-request-batch',
|
||||
text: intl.formatMessage(messages.declinerequests, {
|
||||
requestCount: activeRequests.length,
|
||||
}),
|
||||
action: () => {
|
||||
modifyRequests(activeRequests, 'decline');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
active4kRequest &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
mediaType === 'movie'
|
||||
) {
|
||||
buttons.push(
|
||||
{
|
||||
id: 'approve-4k-request',
|
||||
text: intl.formatMessage(messages.approverequest4k),
|
||||
action: () => {
|
||||
modifyRequest(active4kRequest, 'approve');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'decline-4k-request',
|
||||
text: intl.formatMessage(messages.declinerequest4k),
|
||||
action: () => {
|
||||
modifyRequest(active4kRequest, 'decline');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
active4kRequests &&
|
||||
active4kRequests.length > 0 &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
mediaType === 'tv'
|
||||
) {
|
||||
buttons.push(
|
||||
{
|
||||
id: 'approve-request-batch',
|
||||
text: intl.formatMessage(messages.approve4krequests, {
|
||||
requestCount: active4kRequests.length,
|
||||
}),
|
||||
action: () => {
|
||||
modifyRequests(active4kRequests, 'approve');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'decline-request-batch',
|
||||
text: intl.formatMessage(messages.decline4krequests, {
|
||||
requestCount: active4kRequests.length,
|
||||
}),
|
||||
action: () => {
|
||||
modifyRequests(active4kRequests, 'decline');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const [buttonOne, ...others] = buttons;
|
||||
|
||||
if (!buttonOne) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<RequestModal
|
||||
tmdbId={tmdbId}
|
||||
show={showRequestModal}
|
||||
type={mediaType}
|
||||
onComplete={() => {
|
||||
onUpdate();
|
||||
setShowRequestModal(false);
|
||||
}}
|
||||
onCancel={() => setShowRequestModal(false)}
|
||||
/>
|
||||
<RequestModal
|
||||
tmdbId={tmdbId}
|
||||
show={showRequest4kModal}
|
||||
type={mediaType}
|
||||
is4k
|
||||
onComplete={() => {
|
||||
onUpdate();
|
||||
setShowRequest4kModal(false);
|
||||
}}
|
||||
onCancel={() => setShowRequest4kModal(false)}
|
||||
/>
|
||||
<ButtonWithDropdown
|
||||
text={
|
||||
<>
|
||||
{buttonOne.svg ?? null}
|
||||
{buttonOne.text}
|
||||
</>
|
||||
}
|
||||
onClick={buttonOne.action}
|
||||
className="ml-2"
|
||||
>
|
||||
{others && others.length > 0
|
||||
? others.map((button) => (
|
||||
<ButtonWithDropdown.Item
|
||||
onClick={button.action}
|
||||
key={`request-option-${button.id}`}
|
||||
>
|
||||
{button.svg}
|
||||
{button.text}
|
||||
</ButtonWithDropdown.Item>
|
||||
))
|
||||
: null}
|
||||
</ButtonWithDropdown>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestButton;
|
||||
@@ -28,7 +28,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||
|
||||
const RequestCardPlaceholder: React.FC = () => {
|
||||
return (
|
||||
<div className="w-72 sm:w-96 relative animate-pulse rounded-lg bg-gray-700 p-4">
|
||||
<div className="relative p-4 bg-gray-700 rounded-lg w-72 sm:w-96 animate-pulse">
|
||||
<div className="w-20 sm:w-28">
|
||||
<div className="w-full" style={{ paddingBottom: '150%' }} />
|
||||
</div>
|
||||
@@ -88,13 +88,13 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-72 sm:w-96 p-4 bg-gray-800 rounded-md flex bg-cover bg-center text-gray-400"
|
||||
className="relative flex p-4 text-gray-400 bg-gray-800 bg-center bg-cover rounded-md w-72 sm:w-96"
|
||||
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})`,
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 pr-4 min-w-0 flex flex-col">
|
||||
<h2 className="text-base sm:text-lg overflow-ellipsis overflow-hidden whitespace-nowrap text-white cursor-pointer hover:underline">
|
||||
<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
|
||||
href={request.type === 'movie' ? '/movie/[movieId]' : '/tv/[tvId]'}
|
||||
as={
|
||||
@@ -106,18 +106,25 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
|
||||
{isMovie(title) ? title.title : title.name}
|
||||
</Link>
|
||||
</h2>
|
||||
<div className="text-xs sm:text-sm truncate">
|
||||
<div className="text-xs truncate sm:text-sm">
|
||||
{intl.formatMessage(messages.requestedby, {
|
||||
username: requestData.requestedBy.username,
|
||||
})}
|
||||
</div>
|
||||
{requestData.media.status && (
|
||||
<div className="mt-1 sm:mt-2">
|
||||
<StatusBadge status={requestData.media.status} />
|
||||
<StatusBadge
|
||||
status={
|
||||
requestData.is4k
|
||||
? requestData.media.status4k
|
||||
: requestData.media.status
|
||||
}
|
||||
is4k={requestData.is4k}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{request.seasons.length > 0 && (
|
||||
<div className="hidden mt-2 text-sm sm:flex items-center">
|
||||
<div className="items-center hidden mt-2 text-sm sm:flex">
|
||||
<span className="mr-2">{intl.formatMessage(messages.seasons)}</span>
|
||||
{!isMovie(title) &&
|
||||
title.seasons.filter((season) => season.seasonNumber !== 0)
|
||||
@@ -126,7 +133,7 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
|
||||
<Badge>{intl.formatMessage(messages.all)}</Badge>
|
||||
</span>
|
||||
) : (
|
||||
<div className="hide-scrollbar overflow-x-scroll">
|
||||
<div className="overflow-x-scroll hide-scrollbar">
|
||||
{request.seasons.map((season) => (
|
||||
<span key={`season-${season.id}`} className="mr-2">
|
||||
<Badge>{season.seasonNumber}</Badge>
|
||||
@@ -138,7 +145,7 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
|
||||
)}
|
||||
{requestData.status === MediaRequestStatus.PENDING &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<div className="flex-1 flex items-end">
|
||||
<div className="flex items-end flex-1">
|
||||
<span className="mr-2">
|
||||
<Button
|
||||
buttonType="success"
|
||||
@@ -200,7 +207,7 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
|
||||
<img
|
||||
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`}
|
||||
alt=""
|
||||
className="w-20 sm:w-28 rounded-md shadow-sm cursor-pointer transition transform-gpu duration-300 scale-100 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"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ import axios from 'axios';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import Link from 'next/link';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import RequestModal from '../../RequestModal';
|
||||
|
||||
const messages = defineMessages({
|
||||
requestedby: 'Requested by {username}',
|
||||
@@ -51,6 +52,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
||||
const { addToast } = useToasts();
|
||||
const intl = useIntl();
|
||||
const { hasPermission } = useUser();
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const url =
|
||||
request.type === 'movie'
|
||||
@@ -116,6 +118,18 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
||||
|
||||
return (
|
||||
<tr className="relative w-full h-24 p-2 text-white bg-gray-800">
|
||||
<RequestModal
|
||||
show={showEditModal}
|
||||
tmdbId={request.media.tmdbId}
|
||||
type={request.type}
|
||||
is4k={request.is4k}
|
||||
editRequest={request}
|
||||
onCancel={() => setShowEditModal(false)}
|
||||
onComplete={() => {
|
||||
revalidateList();
|
||||
setShowEditModal(false);
|
||||
}}
|
||||
/>
|
||||
<Table.TD>
|
||||
<div className="flex items-center">
|
||||
<Link
|
||||
@@ -166,9 +180,12 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
||||
</div>
|
||||
</Table.TD>
|
||||
<Table.TD>
|
||||
{requestData.media.status === MediaStatus.UNKNOWN ? (
|
||||
{requestData.media.status === MediaStatus.UNKNOWN ||
|
||||
requestData.status === MediaRequestStatus.DECLINED ? (
|
||||
<Badge badgeType="danger">
|
||||
{intl.formatMessage(globalMessages.failed)}
|
||||
{requestData.status === MediaRequestStatus.DECLINED
|
||||
? intl.formatMessage(globalMessages.declined)
|
||||
: intl.formatMessage(globalMessages.failed)}
|
||||
</Badge>
|
||||
) : (
|
||||
<StatusBadge status={requestData.media.status} />
|
||||
@@ -202,6 +219,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
||||
</Table.TD>
|
||||
<Table.TD alignText="right">
|
||||
{requestData.media.status === MediaStatus.UNKNOWN &&
|
||||
requestData.status !== MediaRequestStatus.DECLINED &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<Button
|
||||
className="mr-2"
|
||||
@@ -276,7 +294,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
<span>
|
||||
<span className="mr-2">
|
||||
<Button
|
||||
buttonType="danger"
|
||||
buttonSize="sm"
|
||||
@@ -299,6 +317,25 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
<span>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
onClick={() => setShowEditModal(true)}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-0 sm:mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
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" />
|
||||
</svg>
|
||||
<span className="hidden sm:block">
|
||||
{intl.formatMessage(globalMessages.edit)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Table.TD>
|
||||
|
||||
312
src/components/RequestModal/AdvancedRequester/index.tsx
Normal file
312
src/components/RequestModal/AdvancedRequester/index.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { SmallLoadingSpinner } from '../../Common/LoadingSpinner';
|
||||
import type {
|
||||
ServiceCommonServer,
|
||||
ServiceCommonServerWithDetails,
|
||||
} from '../../../../server/interfaces/api/serviceInterfaces';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const formatBytes = (bytes: number, decimals = 2) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const messages = defineMessages({
|
||||
advancedoptions: 'Advanced Options',
|
||||
destinationserver: 'Destination Server',
|
||||
qualityprofile: 'Quality Profile',
|
||||
rootfolder: 'Root Folder',
|
||||
animenote: '* This series is an anime.',
|
||||
default: '(Default)',
|
||||
loadingprofiles: 'Loading profiles…',
|
||||
loadingfolders: 'Loading folders…',
|
||||
});
|
||||
|
||||
export type RequestOverrides = {
|
||||
server?: number;
|
||||
profile?: number;
|
||||
folder?: string;
|
||||
};
|
||||
|
||||
interface AdvancedRequesterProps {
|
||||
type: 'movie' | 'tv';
|
||||
is4k: boolean;
|
||||
isAnime?: boolean;
|
||||
defaultOverrides?: RequestOverrides;
|
||||
onChange: (overrides: RequestOverrides) => void;
|
||||
}
|
||||
|
||||
const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
||||
type,
|
||||
is4k = false,
|
||||
isAnime = false,
|
||||
defaultOverrides,
|
||||
onChange,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { data, error } = useSWR<ServiceCommonServer[]>(
|
||||
`/api/v1/service/${type === 'movie' ? 'radarr' : 'sonarr'}`,
|
||||
{
|
||||
refreshInterval: 0,
|
||||
refreshWhenHidden: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnMount: true,
|
||||
}
|
||||
);
|
||||
const [selectedServer, setSelectedServer] = useState<number | null>(
|
||||
defaultOverrides?.server !== undefined && defaultOverrides?.server >= 0
|
||||
? defaultOverrides?.server
|
||||
: null
|
||||
);
|
||||
const [selectedProfile, setSelectedProfile] = useState<number>(
|
||||
defaultOverrides?.profile ?? -1
|
||||
);
|
||||
const [selectedFolder, setSelectedFolder] = useState<string>(
|
||||
defaultOverrides?.folder ?? ''
|
||||
);
|
||||
const {
|
||||
data: serverData,
|
||||
isValidating,
|
||||
} = useSWR<ServiceCommonServerWithDetails>(
|
||||
selectedServer !== null
|
||||
? `/api/v1/service/${
|
||||
type === 'movie' ? 'radarr' : 'sonarr'
|
||||
}/${selectedServer}`
|
||||
: null,
|
||||
{
|
||||
refreshInterval: 0,
|
||||
refreshWhenHidden: false,
|
||||
revalidateOnFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let defaultServer = data?.find(
|
||||
(server) => server.isDefault && is4k === server.is4k
|
||||
);
|
||||
|
||||
if (!defaultServer && (data ?? []).length > 0) {
|
||||
defaultServer = data?.[0];
|
||||
}
|
||||
|
||||
if (
|
||||
defaultServer &&
|
||||
defaultServer.id !== selectedServer &&
|
||||
(!defaultOverrides || defaultOverrides.server === null)
|
||||
) {
|
||||
setSelectedServer(defaultServer.id);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverData) {
|
||||
const defaultProfile = serverData.profiles.find(
|
||||
(profile) =>
|
||||
profile.id ===
|
||||
(isAnime
|
||||
? serverData.server.activeAnimeProfileId
|
||||
: serverData.server.activeProfileId)
|
||||
);
|
||||
const defaultFolder = serverData.rootFolders.find(
|
||||
(folder) =>
|
||||
folder.path ===
|
||||
(isAnime
|
||||
? serverData.server.activeAnimeDirectory
|
||||
: serverData.server.activeDirectory)
|
||||
);
|
||||
|
||||
if (
|
||||
defaultProfile &&
|
||||
defaultProfile.id !== selectedProfile &&
|
||||
(!defaultOverrides || defaultOverrides.profile === null)
|
||||
) {
|
||||
setSelectedProfile(defaultProfile.id);
|
||||
}
|
||||
|
||||
if (
|
||||
defaultFolder &&
|
||||
defaultFolder.path !== selectedFolder &&
|
||||
(!defaultOverrides || defaultOverrides.folder === null)
|
||||
) {
|
||||
setSelectedFolder(defaultFolder?.path ?? '');
|
||||
}
|
||||
}
|
||||
}, [serverData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
defaultOverrides &&
|
||||
defaultOverrides.server !== null &&
|
||||
defaultOverrides.server !== undefined
|
||||
) {
|
||||
setSelectedServer(defaultOverrides.server);
|
||||
}
|
||||
|
||||
if (
|
||||
defaultOverrides &&
|
||||
defaultOverrides.profile !== null &&
|
||||
defaultOverrides.profile !== undefined
|
||||
) {
|
||||
setSelectedProfile(defaultOverrides.profile);
|
||||
}
|
||||
|
||||
if (
|
||||
defaultOverrides &&
|
||||
defaultOverrides.folder !== null &&
|
||||
defaultOverrides.folder !== undefined
|
||||
) {
|
||||
setSelectedFolder(defaultOverrides.folder);
|
||||
}
|
||||
}, [
|
||||
defaultOverrides?.server,
|
||||
defaultOverrides?.folder,
|
||||
defaultOverrides?.profile,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedServer !== null) {
|
||||
onChange({
|
||||
folder: selectedFolder !== '' ? selectedFolder : undefined,
|
||||
profile: selectedProfile !== -1 ? selectedProfile : undefined,
|
||||
server: selectedServer ?? undefined,
|
||||
});
|
||||
}
|
||||
}, [selectedFolder, selectedServer, selectedProfile]);
|
||||
|
||||
if (!data && !error) {
|
||||
return (
|
||||
<div className="w-full mb-2">
|
||||
<SmallLoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || selectedServer === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center mb-2 font-bold tracking-wider">
|
||||
<svg
|
||||
className="w-4 h-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M9.707 7.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L13 8.586V5h3a2 2 0 012 2v5a2 2 0 01-2 2H8a2 2 0 01-2-2V7a2 2 0 012-2h3v3.586L9.707 7.293zM11 3a1 1 0 112 0v2h-2V3z" />
|
||||
<path d="M4 9a2 2 0 00-2 2v5a2 2 0 002 2h8a2 2 0 002-2H4V9z" />
|
||||
</svg>
|
||||
{intl.formatMessage(messages.advancedoptions)}
|
||||
</div>
|
||||
<div className="p-4 bg-gray-600 rounded-md shadow">
|
||||
<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/3 md:pr-4 md:mb-0">
|
||||
<label htmlFor="server" className="block text-sm font-medium">
|
||||
{intl.formatMessage(messages.destinationserver)}
|
||||
</label>
|
||||
<select
|
||||
id="server"
|
||||
name="server"
|
||||
onChange={(e) => setSelectedServer(Number(e.target.value))}
|
||||
onBlur={(e) => setSelectedServer(Number(e.target.value))}
|
||||
value={selectedServer}
|
||||
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"
|
||||
>
|
||||
{data.map((server) => (
|
||||
<option key={`server-list-${server.id}`} value={server.id}>
|
||||
{server.name}
|
||||
{server.isDefault && server.is4k === is4k
|
||||
? ` ${intl.formatMessage(messages.default)}`
|
||||
: ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/3 md:pr-4 md:mb-0">
|
||||
<label htmlFor="server" className="block text-sm font-medium">
|
||||
{intl.formatMessage(messages.qualityprofile)}
|
||||
</label>
|
||||
<select
|
||||
id="profile"
|
||||
name="profile"
|
||||
value={selectedProfile}
|
||||
onChange={(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"
|
||||
>
|
||||
{isValidating && (
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.loadingprofiles)}
|
||||
</option>
|
||||
)}
|
||||
{!isValidating &&
|
||||
serverData &&
|
||||
serverData.profiles.map((profile) => (
|
||||
<option key={`profile-list${profile.id}`} value={profile.id}>
|
||||
{profile.name}
|
||||
{isAnime &&
|
||||
serverData.server.activeAnimeProfileId === profile.id
|
||||
? ` ${intl.formatMessage(messages.default)}`
|
||||
: !isAnime &&
|
||||
serverData.server.activeProfileId === profile.id
|
||||
? ` ${intl.formatMessage(messages.default)}`
|
||||
: ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/3 md:mb-0">
|
||||
<label htmlFor="server" className="block text-sm font-medium">
|
||||
{intl.formatMessage(messages.rootfolder)}
|
||||
</label>
|
||||
<select
|
||||
id="folder"
|
||||
name="folder"
|
||||
value={selectedFolder}
|
||||
onChange={(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"
|
||||
>
|
||||
{isValidating && (
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.loadingfolders)}
|
||||
</option>
|
||||
)}
|
||||
{!isValidating &&
|
||||
serverData &&
|
||||
serverData.rootFolders.map((folder) => (
|
||||
<option key={`folder-list${folder.id}`} value={folder.path}>
|
||||
{folder.path} ({formatBytes(folder.freeSpace ?? 0)})
|
||||
{isAnime &&
|
||||
serverData.server.activeAnimeDirectory === folder.path
|
||||
? ` ${intl.formatMessage(messages.default)}`
|
||||
: !isAnime &&
|
||||
serverData.server.activeDirectory === folder.path
|
||||
? ` ${intl.formatMessage(messages.default)}`
|
||||
: ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{isAnime && (
|
||||
<div className="mt-4 italic">
|
||||
{intl.formatMessage(messages.animenote)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedRequester;
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
MediaRequestStatus,
|
||||
} from '../../../server/constants/media';
|
||||
import DownloadIcon from '../../assets/download.svg';
|
||||
import Alert from '../Common/Alert';
|
||||
import AdvancedRequester, { RequestOverrides } from './AdvancedRequester';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
|
||||
const messages = defineMessages({
|
||||
requestadmin:
|
||||
@@ -22,17 +25,25 @@ const messages = defineMessages({
|
||||
requestSuccess: '<strong>{title}</strong> successfully requested!',
|
||||
requestCancel: 'Request for <strong>{title}</strong> cancelled',
|
||||
requesttitle: 'Request {title}',
|
||||
request4ktitle: 'Request {title} in 4K',
|
||||
close: 'Close',
|
||||
cancel: 'Cancel Request',
|
||||
cancelling: 'Cancelling...',
|
||||
pendingrequest: 'Pending request for {title}',
|
||||
pending4krequest: 'Pending request for {title} in 4K',
|
||||
requesting: 'Requesting...',
|
||||
request: 'Request',
|
||||
request4k: 'Request 4K',
|
||||
requestfrom: 'There is currently a pending request from {username}',
|
||||
request4kfrom: 'There is currently a pending 4K request from {username}',
|
||||
errorediting: 'Something went wrong editing the request.',
|
||||
requestedited: 'Request edited.',
|
||||
});
|
||||
|
||||
interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
tmdbId: number;
|
||||
is4k?: boolean;
|
||||
editRequest?: MediaRequest;
|
||||
onCancel?: () => void;
|
||||
onComplete?: (newStatus: MediaStatus) => void;
|
||||
onUpdating?: (isUpdating: boolean) => void;
|
||||
@@ -43,8 +54,14 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
onComplete,
|
||||
tmdbId,
|
||||
onUpdating,
|
||||
editRequest,
|
||||
is4k = false,
|
||||
}) => {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [
|
||||
requestOverrides,
|
||||
setRequestOverrides,
|
||||
] = useState<RequestOverrides | null>(null);
|
||||
const { addToast } = useToasts();
|
||||
const { data, error } = useSWR<MovieDetails>(`/api/v1/movie/${tmdbId}`, {
|
||||
revalidateOnMount: true,
|
||||
@@ -60,9 +77,19 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
|
||||
const sendRequest = useCallback(async () => {
|
||||
setIsUpdating(true);
|
||||
let overrideParams = {};
|
||||
if (requestOverrides) {
|
||||
overrideParams = {
|
||||
serverId: requestOverrides.server,
|
||||
profileId: requestOverrides.profile,
|
||||
rootFolder: requestOverrides.folder,
|
||||
};
|
||||
}
|
||||
const response = await axios.post<MediaRequest>('/api/v1/request', {
|
||||
mediaId: data?.id,
|
||||
mediaType: 'movie',
|
||||
is4k,
|
||||
...overrideParams,
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
@@ -87,9 +114,11 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
);
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [data, onComplete, addToast]);
|
||||
}, [data, onComplete, addToast, requestOverrides]);
|
||||
|
||||
const activeRequest = data?.mediaInfo?.requests?.[0];
|
||||
const activeRequest = data?.mediaInfo?.requests?.find(
|
||||
(request) => request.is4k === !!is4k
|
||||
);
|
||||
|
||||
const cancelRequest = async () => {
|
||||
setIsUpdating(true);
|
||||
@@ -116,38 +145,93 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const updateRequest = async () => {
|
||||
setIsUpdating(true);
|
||||
|
||||
try {
|
||||
await axios.put(`/api/v1/request/${editRequest?.id}`, {
|
||||
mediaType: 'movie',
|
||||
serverId: requestOverrides?.server,
|
||||
profileId: requestOverrides?.profile,
|
||||
rootFolder: requestOverrides?.folder,
|
||||
});
|
||||
|
||||
addToast(<span>{intl.formatMessage(messages.requestedited)}</span>, {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
|
||||
if (onComplete) {
|
||||
onComplete(MediaStatus.PENDING);
|
||||
}
|
||||
} catch (e) {
|
||||
addToast(<span>{intl.formatMessage(messages.errorediting)}</span>, {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isOwner = activeRequest
|
||||
? activeRequest.requestedBy.id === user?.id ||
|
||||
hasPermission(Permission.MANAGE_REQUESTS)
|
||||
: false;
|
||||
|
||||
const text = hasPermission(Permission.MANAGE_REQUESTS)
|
||||
? intl.formatMessage(messages.requestadmin)
|
||||
: undefined;
|
||||
|
||||
if (activeRequest?.status === MediaRequestStatus.PENDING) {
|
||||
return (
|
||||
<Modal
|
||||
loading={!data && !error}
|
||||
backgroundClickable
|
||||
onCancel={onCancel}
|
||||
onOk={isOwner ? () => cancelRequest() : undefined}
|
||||
title={intl.formatMessage(
|
||||
is4k ? messages.pending4krequest : messages.pendingrequest,
|
||||
{
|
||||
title: data?.title,
|
||||
}
|
||||
)}
|
||||
onOk={() => updateRequest()}
|
||||
okDisabled={isUpdating}
|
||||
title={intl.formatMessage(messages.pendingrequest, {
|
||||
title: data?.title,
|
||||
})}
|
||||
okText={
|
||||
okText={intl.formatMessage(globalMessages.edit)}
|
||||
okButtonType="primary"
|
||||
onSecondary={isOwner ? () => cancelRequest() : undefined}
|
||||
secondaryDisabled={isUpdating}
|
||||
secondaryText={
|
||||
isUpdating
|
||||
? intl.formatMessage(messages.cancelling)
|
||||
: intl.formatMessage(messages.cancel)
|
||||
}
|
||||
okButtonType={'danger'}
|
||||
secondaryButtonType="danger"
|
||||
cancelText={intl.formatMessage(messages.close)}
|
||||
iconSvg={<DownloadIcon className="w-6 h-6" />}
|
||||
>
|
||||
{intl.formatMessage(messages.requestfrom, {
|
||||
username: activeRequest.requestedBy.username,
|
||||
})}
|
||||
{intl.formatMessage(
|
||||
is4k ? messages.request4kfrom : messages.requestfrom,
|
||||
{
|
||||
username: activeRequest.requestedBy.username,
|
||||
}
|
||||
)}
|
||||
{hasPermission(Permission.REQUEST_ADVANCED) && (
|
||||
<div className="mt-4">
|
||||
<AdvancedRequester
|
||||
type="movie"
|
||||
is4k={is4k}
|
||||
defaultOverrides={
|
||||
editRequest
|
||||
? {
|
||||
folder: editRequest.rootFolder,
|
||||
profile: editRequest.profileId,
|
||||
server: editRequest.serverId,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onChange={(overrides) => {
|
||||
setRequestOverrides(overrides);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -159,16 +243,36 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
onCancel={onCancel}
|
||||
onOk={sendRequest}
|
||||
okDisabled={isUpdating}
|
||||
title={intl.formatMessage(messages.requesttitle, { title: data?.title })}
|
||||
title={intl.formatMessage(
|
||||
is4k ? messages.request4ktitle : messages.requesttitle,
|
||||
{ title: data?.title }
|
||||
)}
|
||||
okText={
|
||||
isUpdating
|
||||
? intl.formatMessage(messages.requesting)
|
||||
: intl.formatMessage(messages.request)
|
||||
: intl.formatMessage(is4k ? messages.request4k : messages.request)
|
||||
}
|
||||
okButtonType={'primary'}
|
||||
iconSvg={<DownloadIcon className="w-6 h-6" />}
|
||||
>
|
||||
<p className="text-center md:text-left">{text}</p>
|
||||
{(hasPermission(Permission.MANAGE_REQUESTS) ||
|
||||
hasPermission(Permission.AUTO_APPROVE) ||
|
||||
hasPermission(Permission.AUTO_APPROVE_MOVIE)) && (
|
||||
<p className="mt-6">
|
||||
<Alert title="Auto Approval" type="info">
|
||||
{intl.formatMessage(messages.requestadmin)}
|
||||
</Alert>
|
||||
</p>
|
||||
)}
|
||||
{hasPermission(Permission.REQUEST_ADVANCED) && (
|
||||
<AdvancedRequester
|
||||
type="movie"
|
||||
is4k={is4k}
|
||||
onChange={(overrides) => {
|
||||
setRequestOverrides(overrides);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||
import { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
import useSWR from 'swr';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
MediaStatus,
|
||||
@@ -15,14 +16,16 @@ import { TvDetails } from '../../../server/models/Tv';
|
||||
import Badge from '../Common/Badge';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import SeasonRequest from '../../../server/entity/SeasonRequest';
|
||||
import Alert from '../Common/Alert';
|
||||
import AdvancedRequester, { RequestOverrides } from './AdvancedRequester';
|
||||
|
||||
const messages = defineMessages({
|
||||
requestadmin: 'Your request will be immediately approved.',
|
||||
cancelrequest:
|
||||
'This will remove your request. Are you sure you want to continue?',
|
||||
requestSuccess: '<strong>{title}</strong> successfully requested!',
|
||||
requestCancel: 'Request for <strong>{title}</strong> cancelled',
|
||||
requesttitle: 'Request {title}',
|
||||
request4ktitle: 'Request {title} in 4K',
|
||||
requesting: 'Requesting...',
|
||||
requestseasons:
|
||||
'Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}}',
|
||||
@@ -33,6 +36,9 @@ const messages = defineMessages({
|
||||
seasonnumber: 'Season {number}',
|
||||
extras: 'Extras',
|
||||
notrequested: 'Not Requested',
|
||||
errorediting: 'Something went wrong editing the request.',
|
||||
requestedited: 'Request edited.',
|
||||
requestcancelled: 'Request cancelled.',
|
||||
});
|
||||
|
||||
interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
@@ -40,6 +46,8 @@ interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
onCancel?: () => void;
|
||||
onComplete?: (newStatus: MediaStatus) => void;
|
||||
onUpdating?: (isUpdating: boolean) => void;
|
||||
is4k?: boolean;
|
||||
editRequest?: MediaRequest;
|
||||
}
|
||||
|
||||
const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
@@ -47,13 +55,72 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
onComplete,
|
||||
tmdbId,
|
||||
onUpdating,
|
||||
editRequest,
|
||||
is4k = false,
|
||||
}) => {
|
||||
const { addToast } = useToasts();
|
||||
const editingSeasons: number[] = (editRequest?.seasons ?? []).map(
|
||||
(season) => season.seasonNumber
|
||||
);
|
||||
const { data, error } = useSWR<TvDetails>(`/api/v1/tv/${tmdbId}`);
|
||||
const [selectedSeasons, setSelectedSeasons] = useState<number[]>([]);
|
||||
const [
|
||||
requestOverrides,
|
||||
setRequestOverrides,
|
||||
] = useState<RequestOverrides | null>(null);
|
||||
const [selectedSeasons, setSelectedSeasons] = useState<number[]>(
|
||||
editRequest ? editingSeasons : []
|
||||
);
|
||||
const intl = useIntl();
|
||||
const { hasPermission } = useUser();
|
||||
|
||||
const updateRequest = async () => {
|
||||
if (!editRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onUpdating) {
|
||||
onUpdating(true);
|
||||
}
|
||||
|
||||
try {
|
||||
if (selectedSeasons.length > 0) {
|
||||
await axios.put(`/api/v1/request/${editRequest.id}`, {
|
||||
mediaType: 'tv',
|
||||
serverId: requestOverrides?.server,
|
||||
profileId: requestOverrides?.profile,
|
||||
rootFolder: requestOverrides?.folder,
|
||||
seasons: selectedSeasons,
|
||||
});
|
||||
} else {
|
||||
await axios.delete(`/api/v1/request/${editRequest.id}`);
|
||||
}
|
||||
|
||||
addToast(
|
||||
<span>
|
||||
{selectedSeasons.length > 0
|
||||
? intl.formatMessage(messages.requestedited)
|
||||
: intl.formatMessage(messages.requestcancelled)}
|
||||
</span>,
|
||||
{
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
if (onComplete) {
|
||||
onComplete(MediaStatus.PENDING);
|
||||
}
|
||||
} catch (e) {
|
||||
addToast(<span>{intl.formatMessage(messages.errorediting)}</span>, {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} finally {
|
||||
if (onUpdating) {
|
||||
onUpdating(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sendRequest = async () => {
|
||||
if (selectedSeasons.length === 0) {
|
||||
return;
|
||||
@@ -61,11 +128,21 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
if (onUpdating) {
|
||||
onUpdating(true);
|
||||
}
|
||||
let overrideParams = {};
|
||||
if (requestOverrides) {
|
||||
overrideParams = {
|
||||
serverId: requestOverrides.server,
|
||||
profileId: requestOverrides.profile,
|
||||
rootFolder: requestOverrides.folder,
|
||||
};
|
||||
}
|
||||
const response = await axios.post<MediaRequest>('/api/v1/request', {
|
||||
mediaId: data?.id,
|
||||
tvdbId: data?.externalIds.tvdbId,
|
||||
mediaType: 'tv',
|
||||
is4k,
|
||||
seasons: selectedSeasons,
|
||||
...overrideParams,
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
@@ -90,21 +167,23 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
};
|
||||
|
||||
const getAllRequestedSeasons = (): number[] => {
|
||||
const requestedSeasons = (data?.mediaInfo?.requests ?? []).reduce(
|
||||
(requestedSeasons, request) => {
|
||||
const requestedSeasons = (data?.mediaInfo?.requests ?? [])
|
||||
.filter((request) => request.is4k === is4k)
|
||||
.reduce((requestedSeasons, request) => {
|
||||
return [
|
||||
...requestedSeasons,
|
||||
...request.seasons.map((sr) => sr.seasonNumber),
|
||||
...request.seasons
|
||||
.filter((season) => !editingSeasons.includes(season.seasonNumber))
|
||||
.map((sr) => sr.seasonNumber),
|
||||
];
|
||||
},
|
||||
[] as number[]
|
||||
);
|
||||
}, [] as number[]);
|
||||
|
||||
const availableSeasons = (data?.mediaInfo?.seasons ?? [])
|
||||
.filter(
|
||||
(season) =>
|
||||
(season.status === MediaStatus.AVAILABLE ||
|
||||
season.status === MediaStatus.PARTIALLY_AVAILABLE) &&
|
||||
(season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
|
||||
season[is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.PARTIALLY_AVAILABLE) &&
|
||||
!requestedSeasons.includes(season.seasonNumber)
|
||||
)
|
||||
.map((season) => season.seasonNumber);
|
||||
@@ -168,22 +247,25 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const text = hasPermission(Permission.MANAGE_REQUESTS)
|
||||
? intl.formatMessage(messages.requestadmin)
|
||||
: undefined;
|
||||
|
||||
const getSeasonRequest = (
|
||||
seasonNumber: number
|
||||
): SeasonRequest | undefined => {
|
||||
let seasonRequest: SeasonRequest | undefined;
|
||||
if (data?.mediaInfo && (data.mediaInfo.requests || []).length > 0) {
|
||||
data.mediaInfo.requests.forEach((request) => {
|
||||
if (!seasonRequest) {
|
||||
seasonRequest = request.seasons.find(
|
||||
(season) => season.seasonNumber === seasonNumber
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
data?.mediaInfo &&
|
||||
(data.mediaInfo.requests || []).filter((request) => request.is4k === is4k)
|
||||
.length > 0
|
||||
) {
|
||||
data.mediaInfo.requests
|
||||
.filter((request) => request.is4k === is4k)
|
||||
.forEach((request) => {
|
||||
if (!seasonRequest) {
|
||||
seasonRequest = request.seasons.find(
|
||||
(season) => season.seasonNumber === seasonNumber
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return seasonRequest;
|
||||
@@ -194,17 +276,24 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
loading={!data && !error}
|
||||
backgroundClickable
|
||||
onCancel={onCancel}
|
||||
onOk={() => sendRequest()}
|
||||
title={intl.formatMessage(messages.requesttitle, { title: data?.name })}
|
||||
onOk={() => (editRequest ? updateRequest() : sendRequest())}
|
||||
title={intl.formatMessage(
|
||||
is4k ? messages.request4ktitle : messages.requesttitle,
|
||||
{ title: data?.name }
|
||||
)}
|
||||
okText={
|
||||
selectedSeasons.length === 0
|
||||
editRequest && selectedSeasons.length === 0
|
||||
? 'Cancel Request'
|
||||
: selectedSeasons.length === 0
|
||||
? intl.formatMessage(messages.selectseason)
|
||||
: intl.formatMessage(messages.requestseasons, {
|
||||
seasonCount: selectedSeasons.length,
|
||||
})
|
||||
}
|
||||
okDisabled={selectedSeasons.length === 0}
|
||||
okButtonType="primary"
|
||||
okDisabled={editRequest ? false : selectedSeasons.length === 0}
|
||||
okButtonType={
|
||||
editRequest && selectedSeasons.length === 0 ? 'danger' : `primary`
|
||||
}
|
||||
iconSvg={
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
@@ -222,8 +311,18 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
{(hasPermission(Permission.MANAGE_REQUESTS) ||
|
||||
hasPermission(Permission.AUTO_APPROVE) ||
|
||||
hasPermission(Permission.AUTO_APPROVE_MOVIE)) &&
|
||||
!editRequest && (
|
||||
<p className="mt-6">
|
||||
<Alert title="Auto Approval" type="info">
|
||||
{intl.formatMessage(messages.requestadmin)}
|
||||
</Alert>
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className="-mx-4 overflow-auto sm:mx-0 max-h-96">
|
||||
<div className="-mx-4 sm:mx-0">
|
||||
<div className="inline-block min-w-full py-2 align-middle">
|
||||
<div className="overflow-hidden shadow sm:rounded-lg">
|
||||
<table className="min-w-full">
|
||||
@@ -256,18 +355,18 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
></span>
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-1 md:px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500">
|
||||
<th className="px-1 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500 md:px-6">
|
||||
{intl.formatMessage(messages.season)}
|
||||
</th>
|
||||
<th className="px-5 md:px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500">
|
||||
<th className="px-5 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500 md:px-6">
|
||||
{intl.formatMessage(messages.numberofepisodes)}
|
||||
</th>
|
||||
<th className="px-2 md:px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500">
|
||||
<th className="px-2 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500 md:px-6">
|
||||
{intl.formatMessage(messages.status)}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-gray-600 divide-y">
|
||||
<tbody className="bg-gray-600 divide-y divide-gray-700">
|
||||
{data?.seasons
|
||||
.filter((season) => season.seasonNumber !== 0)
|
||||
.map((season) => {
|
||||
@@ -275,7 +374,10 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
season.seasonNumber
|
||||
);
|
||||
const mediaSeason = data?.mediaInfo?.seasons.find(
|
||||
(sn) => sn.seasonNumber === season.seasonNumber
|
||||
(sn) =>
|
||||
sn.seasonNumber === season.seasonNumber &&
|
||||
sn[is4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.UNKNOWN
|
||||
);
|
||||
return (
|
||||
<tr key={`season-${season.id}`}>
|
||||
@@ -285,7 +387,10 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
tabIndex={0}
|
||||
aria-checked={
|
||||
!!mediaSeason ||
|
||||
!!seasonRequest ||
|
||||
(!!seasonRequest &&
|
||||
!editingSeasons.includes(
|
||||
season.seasonNumber
|
||||
)) ||
|
||||
isSelectedSeason(season.seasonNumber)
|
||||
}
|
||||
onClick={() => toggleSeason(season.seasonNumber)}
|
||||
@@ -295,14 +400,21 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
}
|
||||
}}
|
||||
className={`group relative inline-flex items-center justify-center flex-shrink-0 h-5 w-10 cursor-pointer focus:outline-none ${
|
||||
mediaSeason || seasonRequest ? 'opacity-50' : ''
|
||||
mediaSeason ||
|
||||
(!!seasonRequest &&
|
||||
!editingSeasons.includes(season.seasonNumber))
|
||||
? 'opacity-50'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
!!mediaSeason ||
|
||||
!!seasonRequest ||
|
||||
(!!seasonRequest &&
|
||||
!editingSeasons.includes(
|
||||
season.seasonNumber
|
||||
)) ||
|
||||
isSelectedSeason(season.seasonNumber)
|
||||
? 'bg-indigo-500'
|
||||
: 'bg-gray-800'
|
||||
@@ -312,7 +424,10 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
!!mediaSeason ||
|
||||
!!seasonRequest ||
|
||||
(!!seasonRequest &&
|
||||
!editingSeasons.includes(
|
||||
season.seasonNumber
|
||||
)) ||
|
||||
isSelectedSeason(season.seasonNumber)
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0'
|
||||
@@ -320,17 +435,17 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
></span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-1 md:px-6 py-4 text-sm font-medium leading-5 text-gray-100 whitespace-nowrap">
|
||||
<td className="px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6 whitespace-nowrap">
|
||||
{season.seasonNumber === 0
|
||||
? intl.formatMessage(messages.extras)
|
||||
: intl.formatMessage(messages.seasonnumber, {
|
||||
number: season.seasonNumber,
|
||||
})}
|
||||
</td>
|
||||
<td className="px-5 md:px-6 py-4 text-sm leading-5 text-gray-200 whitespace-nowrap">
|
||||
<td className="px-5 py-4 text-sm leading-5 text-gray-200 md:px-6 whitespace-nowrap">
|
||||
{season.episodeCount}
|
||||
</td>
|
||||
<td className="pr-2 md:px-6 py-4 text-sm leading-5 text-gray-200 whitespace-nowrap">
|
||||
<td className="py-4 pr-2 text-sm leading-5 text-gray-200 md:px-6 whitespace-nowrap">
|
||||
{!seasonRequest && !mediaSeason && (
|
||||
<Badge>
|
||||
{intl.formatMessage(messages.notrequested)}
|
||||
@@ -357,7 +472,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
{intl.formatMessage(globalMessages.available)}
|
||||
</Badge>
|
||||
)}
|
||||
{mediaSeason?.status ===
|
||||
{mediaSeason?.[is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.PARTIALLY_AVAILABLE && (
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(
|
||||
@@ -365,7 +480,8 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
{mediaSeason?.status === MediaStatus.AVAILABLE && (
|
||||
{mediaSeason?.[is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.AVAILABLE && (
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(globalMessages.available)}
|
||||
</Badge>
|
||||
@@ -380,7 +496,27 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4">{text}</p>
|
||||
{hasPermission(Permission.REQUEST_ADVANCED) && (
|
||||
<div className="mt-4">
|
||||
<AdvancedRequester
|
||||
type="tv"
|
||||
is4k={is4k}
|
||||
isAnime={data?.keywords.some(
|
||||
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
)}
|
||||
onChange={(overrides) => setRequestOverrides(overrides)}
|
||||
defaultOverrides={
|
||||
editRequest
|
||||
? {
|
||||
folder: editRequest.rootFolder,
|
||||
profile: editRequest.profileId,
|
||||
server: editRequest.serverId,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,13 +3,15 @@ import MovieRequestModal from './MovieRequestModal';
|
||||
import type { MediaStatus } from '../../../server/constants/media';
|
||||
import TvRequestModal from './TvRequestModal';
|
||||
import Transition from '../Transition';
|
||||
import { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
|
||||
interface RequestModalProps {
|
||||
show: boolean;
|
||||
type: 'movie' | 'tv';
|
||||
tmdbId: number;
|
||||
is4k?: boolean;
|
||||
editRequest?: MediaRequest;
|
||||
onComplete?: (newStatus: MediaStatus) => void;
|
||||
onError?: (error: string) => void;
|
||||
onCancel?: () => void;
|
||||
onUpdating?: (isUpdating: boolean) => void;
|
||||
}
|
||||
@@ -18,6 +20,8 @@ const RequestModal: React.FC<RequestModalProps> = ({
|
||||
type,
|
||||
show,
|
||||
tmdbId,
|
||||
is4k,
|
||||
editRequest,
|
||||
onComplete,
|
||||
onUpdating,
|
||||
onCancel,
|
||||
@@ -38,6 +42,8 @@ const RequestModal: React.FC<RequestModalProps> = ({
|
||||
onCancel={onCancel}
|
||||
tmdbId={tmdbId}
|
||||
onUpdating={onUpdating}
|
||||
is4k={is4k}
|
||||
editRequest={editRequest}
|
||||
/>
|
||||
</Transition>
|
||||
);
|
||||
@@ -58,6 +64,8 @@ const RequestModal: React.FC<RequestModalProps> = ({
|
||||
onCancel={onCancel}
|
||||
tmdbId={tmdbId}
|
||||
onUpdating={onUpdating}
|
||||
is4k={is4k}
|
||||
editRequest={editRequest}
|
||||
/>
|
||||
</Transition>
|
||||
);
|
||||
|
||||
@@ -20,8 +20,8 @@ const messages = defineMessages({
|
||||
smtpHost: 'SMTP Host',
|
||||
smtpPort: 'SMTP Port',
|
||||
enableSsl: 'Enable SSL',
|
||||
authUser: 'Auth User',
|
||||
authPass: 'Auth Pass',
|
||||
authUser: 'SMTP Username',
|
||||
authPass: 'SMTP Password',
|
||||
emailsettingssaved: 'Email notification settings saved!',
|
||||
emailsettingsfailed: 'Email notification settings failed to save.',
|
||||
test: 'Test',
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import React from 'react';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import dynamic from 'next/dynamic';
|
||||
import useSWR from 'swr';
|
||||
import LoadingSpinner from '../../../Common/LoadingSpinner';
|
||||
import Button from '../../../Common/Button';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import axios from 'axios';
|
||||
import * as Yup from 'yup';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import NotificationTypeSelector from '../../../NotificationTypeSelector';
|
||||
|
||||
const JSONEditor = dynamic(() => import('../../../JSONEditor'), { ssr: false });
|
||||
|
||||
const defaultPayload = {
|
||||
notification_type: '{{notification_type}}',
|
||||
subject: '{{subject}}',
|
||||
message: '{{message}}',
|
||||
image: '{{image}}',
|
||||
email: '{{notifyuser_email}}',
|
||||
username: '{{notifyuser_username}}',
|
||||
avatar: '{{notifyuser_avatar}}',
|
||||
'{{media}}': {
|
||||
media_type: '{{media_type}}',
|
||||
tmdbId: '{{media_tmdbid}}',
|
||||
imdbId: '{{media_imdbid}}',
|
||||
tvdbId: '{{media_tvdbid}}',
|
||||
status: '{{media_status}}',
|
||||
status4k: '{{media_status4k}}',
|
||||
},
|
||||
'{{extra}}': [],
|
||||
};
|
||||
|
||||
const messages = defineMessages({
|
||||
save: 'Save Changes',
|
||||
saving: 'Saving...',
|
||||
agentenabled: 'Agent Enabled',
|
||||
webhookUrl: 'Webhook URL',
|
||||
authheader: 'Authorization Header',
|
||||
validationWebhookUrlRequired: 'You must provide a webhook URL',
|
||||
validationJsonPayloadRequired: 'You must provide a JSON Payload',
|
||||
webhookUrlPlaceholder: 'Remote webhook URL',
|
||||
webhooksettingssaved: 'Webhook notification settings saved!',
|
||||
webhooksettingsfailed: 'Webhook notification settings failed to save.',
|
||||
testsent: 'Test notification sent!',
|
||||
test: 'Test',
|
||||
notificationtypes: 'Notification Types',
|
||||
resetPayload: 'Reset to Default JSON Payload',
|
||||
resetPayloadSuccess: 'JSON reset to default payload.',
|
||||
customJson: 'Custom JSON Payload',
|
||||
templatevariablehelp: 'Template Variable Help',
|
||||
});
|
||||
|
||||
const NotificationsWebhook: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const { data, error, revalidate } = useSWR(
|
||||
'/api/v1/settings/notifications/webhook'
|
||||
);
|
||||
|
||||
const NotificationsWebhookSchema = Yup.object().shape({
|
||||
webhookUrl: Yup.string().required(
|
||||
intl.formatMessage(messages.validationWebhookUrlRequired)
|
||||
),
|
||||
jsonPayload: Yup.string()
|
||||
.required(intl.formatMessage(messages.validationJsonPayloadRequired))
|
||||
.test('validate-json', 'Invalid JSON', (value) => {
|
||||
try {
|
||||
JSON.parse(value ?? '');
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
enabled: data.enabled,
|
||||
types: data.types,
|
||||
webhookUrl: data.options.webhookUrl,
|
||||
jsonPayload: data.options.jsonPayload,
|
||||
authHeader: data.options.authHeader,
|
||||
}}
|
||||
validationSchema={NotificationsWebhookSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await axios.post('/api/v1/settings/notifications/webhook', {
|
||||
enabled: values.enabled,
|
||||
types: values.types,
|
||||
options: {
|
||||
webhookUrl: values.webhookUrl,
|
||||
jsonPayload: JSON.stringify(values.jsonPayload),
|
||||
authHeader: values.authHeader,
|
||||
},
|
||||
});
|
||||
addToast(intl.formatMessage(messages.webhooksettingssaved), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.webhooksettingsfailed), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} finally {
|
||||
revalidate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({
|
||||
errors,
|
||||
touched,
|
||||
isSubmitting,
|
||||
values,
|
||||
isValid,
|
||||
setFieldValue,
|
||||
setFieldTouched,
|
||||
}) => {
|
||||
const resetPayload = () => {
|
||||
setFieldValue(
|
||||
'jsonPayload',
|
||||
JSON.stringify(defaultPayload, undefined, ' ')
|
||||
);
|
||||
addToast(intl.formatMessage(messages.resetPayloadSuccess), {
|
||||
appearance: 'info',
|
||||
autoDismiss: true,
|
||||
});
|
||||
};
|
||||
|
||||
const testSettings = async () => {
|
||||
await axios.post('/api/v1/settings/notifications/webhook/test', {
|
||||
enabled: true,
|
||||
types: values.types,
|
||||
options: {
|
||||
webhookUrl: values.webhookUrl,
|
||||
jsonPayload: JSON.stringify(values.jsonPayload),
|
||||
authHeader: values.authHeader,
|
||||
},
|
||||
});
|
||||
|
||||
addToast(intl.formatMessage(messages.testsent), {
|
||||
appearance: 'info',
|
||||
autoDismiss: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
|
||||
<label
|
||||
htmlFor="enabled"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.agentenabled)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
name="enabled"
|
||||
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.webhookUrl)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="webhookUrl"
|
||||
name="webhookUrl"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(
|
||||
messages.webhookUrlPlaceholder
|
||||
)}
|
||||
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
|
||||
/>
|
||||
</div>
|
||||
{errors.webhookUrl && touched.webhookUrl && (
|
||||
<div className="mt-2 text-red-500">{errors.webhookUrl}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.authheader)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="authHeader"
|
||||
name="authHeader"
|
||||
type="text"
|
||||
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.customJson)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<JSONEditor
|
||||
name="webhook-json-payload"
|
||||
onUpdate={(value) => setFieldValue('jsonPayload', value)}
|
||||
value={values.jsonPayload}
|
||||
onBlur={() => setFieldTouched('jsonPayload')}
|
||||
/>
|
||||
</div>
|
||||
{errors.jsonPayload && touched.jsonPayload && (
|
||||
<div className="mt-2 text-red-500">{errors.jsonPayload}</div>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
buttonSize="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
resetPayload();
|
||||
}}
|
||||
className="mr-2"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{intl.formatMessage(messages.resetPayload)}
|
||||
</Button>
|
||||
<a
|
||||
href="https://docs.overseerr.dev/using-overseerr/notifications/webhooks#template-variables"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center justify-center font-medium leading-5 text-white transition duration-150 ease-in-out bg-indigo-600 border border-transparent rounded-md focus:outline-none hover:bg-indigo-500 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 disabled:opacity-50 px-2.5 py-1.5 text-xs"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{intl.formatMessage(messages.templatevariablehelp)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<div role="group" aria-labelledby="label-permissions">
|
||||
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-baseline">
|
||||
<div>
|
||||
<div
|
||||
className="text-base font-medium leading-6 text-gray-400 sm:text-sm sm:leading-5"
|
||||
id="label-types"
|
||||
>
|
||||
{intl.formatMessage(messages.notificationtypes)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg">
|
||||
<NotificationTypeSelector
|
||||
currentTypes={values.types}
|
||||
onUpdate={(newTypes) =>
|
||||
setFieldValue('types', newTypes)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-5 mt-8 border-t border-gray-700">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="warning"
|
||||
disabled={isSubmitting || !isValid}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
testSettings();
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.test)}
|
||||
</Button>
|
||||
</span>
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.saving)
|
||||
: intl.formatMessage(messages.save)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsWebhook;
|
||||
@@ -286,7 +286,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
type="checkbox"
|
||||
id="isDefault"
|
||||
name="isDefault"
|
||||
className="form-checkbox h-6 w-6 text-indigo-600 transition duration-150 ease-in-out rounded-md"
|
||||
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -296,9 +296,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.servername)}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="name"
|
||||
name="name"
|
||||
@@ -310,11 +311,11 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
setIsValidated(false);
|
||||
setFieldValue('name', e.target.value);
|
||||
}}
|
||||
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
|
||||
/>
|
||||
</div>
|
||||
{errors.name && touched.name && (
|
||||
<div className="text-red-500 mt-2">{errors.name}</div>
|
||||
<div className="mt-2 text-red-500">{errors.name}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -324,10 +325,11 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.hostname)}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-500 bg-gray-600 text-gray-100 sm:text-sm cursor-default">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center px-3 text-gray-100 bg-gray-600 border border-r-0 border-gray-500 cursor-default rounded-l-md sm:text-sm">
|
||||
{values.ssl ? 'https://' : 'http://'}
|
||||
</span>
|
||||
<Field
|
||||
@@ -339,11 +341,11 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
setIsValidated(false);
|
||||
setFieldValue('hostname', e.target.value);
|
||||
}}
|
||||
className="flex-1 form-input block w-full min-w-0 rounded-r-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 form-input rounded-r-md sm:text-sm sm:leading-5"
|
||||
/>
|
||||
</div>
|
||||
{errors.hostname && touched.hostname && (
|
||||
<div className="text-red-500 mt-2">{errors.hostname}</div>
|
||||
<div className="mt-2 text-red-500">{errors.hostname}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -353,6 +355,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.port)}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<Field
|
||||
@@ -364,10 +367,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
setIsValidated(false);
|
||||
setFieldValue('port', e.target.value);
|
||||
}}
|
||||
className="rounded-md shadow-sm form-input block w-24 transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
className="block w-24 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md shadow-sm form-input sm:text-sm sm:leading-5"
|
||||
/>
|
||||
{errors.port && touched.port && (
|
||||
<div className="text-red-500 mt-2">{errors.port}</div>
|
||||
<div className="mt-2 text-red-500">{errors.port}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -387,7 +390,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
setIsValidated(false);
|
||||
setFieldValue('ssl', !values.ssl);
|
||||
}}
|
||||
className="form-checkbox h-6 w-6 rounded-md text-indigo-600 transition duration-150 ease-in-out"
|
||||
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -397,9 +400,10 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.apiKey)}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="apiKey"
|
||||
name="apiKey"
|
||||
@@ -411,11 +415,11 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
setIsValidated(false);
|
||||
setFieldValue('apiKey', e.target.value);
|
||||
}}
|
||||
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
|
||||
/>
|
||||
</div>
|
||||
{errors.apiKey && touched.apiKey && (
|
||||
<div className="text-red-500 mt-2">{errors.apiKey}</div>
|
||||
<div className="mt-2 text-red-500">{errors.apiKey}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -427,7 +431,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
{intl.formatMessage(messages.baseUrl)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="baseUrl"
|
||||
name="baseUrl"
|
||||
@@ -439,11 +443,11 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
setIsValidated(false);
|
||||
setFieldValue('baseUrl', e.target.value);
|
||||
}}
|
||||
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
|
||||
/>
|
||||
</div>
|
||||
{errors.baseUrl && touched.baseUrl && (
|
||||
<div className="text-red-500 mt-2">{errors.baseUrl}</div>
|
||||
<div className="mt-2 text-red-500">{errors.baseUrl}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -453,15 +457,16 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.qualityprofile)}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
as="select"
|
||||
id="activeProfileId"
|
||||
name="activeProfileId"
|
||||
disabled={!isValidated || isTesting}
|
||||
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
|
||||
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 bg-gray-700 border-gray-500 rounded-md form-select focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
|
||||
>
|
||||
<option value="">
|
||||
{isTesting
|
||||
@@ -484,7 +489,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
</Field>
|
||||
</div>
|
||||
{errors.activeProfileId && touched.activeProfileId && (
|
||||
<div className="text-red-500 mt-2">
|
||||
<div className="mt-2 text-red-500">
|
||||
{errors.activeProfileId}
|
||||
</div>
|
||||
)}
|
||||
@@ -496,15 +501,16 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.rootfolder)}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
as="select"
|
||||
id="rootFolder"
|
||||
name="rootFolder"
|
||||
disabled={!isValidated || isTesting}
|
||||
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
|
||||
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 bg-gray-700 border-gray-500 rounded-md form-select focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
|
||||
>
|
||||
<option value="">
|
||||
{isTesting
|
||||
@@ -525,7 +531,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
</Field>
|
||||
</div>
|
||||
{errors.rootFolder && touched.rootFolder && (
|
||||
<div className="text-red-500 mt-2">
|
||||
<div className="mt-2 text-red-500">
|
||||
{errors.rootFolder}
|
||||
</div>
|
||||
)}
|
||||
@@ -537,14 +543,15 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.minimumAvailability)}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
as="select"
|
||||
id="minimumAvailability"
|
||||
name="minimumAvailability"
|
||||
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5"
|
||||
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 bg-gray-700 border-gray-500 rounded-md form-select focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5"
|
||||
>
|
||||
<option value="announced">Announced</option>
|
||||
<option value="inCinemas">In Cinemas</option>
|
||||
@@ -554,7 +561,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
</div>
|
||||
{errors.minimumAvailability &&
|
||||
touched.minimumAvailability && (
|
||||
<div className="text-red-500 mt-2">
|
||||
<div className="mt-2 text-red-500">
|
||||
{errors.minimumAvailability}
|
||||
</div>
|
||||
)}
|
||||
@@ -572,7 +579,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
type="checkbox"
|
||||
id="is4k"
|
||||
name="is4k"
|
||||
className="form-checkbox h-6 w-6 rounded-md text-indigo-600 transition duration-150 ease-in-out"
|
||||
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,6 +82,16 @@ const SettingsMain: React.FC = () => {
|
||||
permissionMessages.managerequestsDescription
|
||||
),
|
||||
permission: Permission.MANAGE_REQUESTS,
|
||||
children: [
|
||||
{
|
||||
id: 'advancedrequest',
|
||||
name: intl.formatMessage(permissionMessages.advancedrequest),
|
||||
description: intl.formatMessage(
|
||||
permissionMessages.advancedrequestDescription
|
||||
),
|
||||
permission: Permission.REQUEST_ADVANCED,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'request',
|
||||
@@ -89,6 +99,30 @@ const SettingsMain: React.FC = () => {
|
||||
description: intl.formatMessage(permissionMessages.requestDescription),
|
||||
permission: Permission.REQUEST,
|
||||
},
|
||||
{
|
||||
id: 'request4k',
|
||||
name: intl.formatMessage(permissionMessages.request4k),
|
||||
description: intl.formatMessage(permissionMessages.request4kDescription),
|
||||
permission: Permission.REQUEST_4K,
|
||||
children: [
|
||||
{
|
||||
id: 'request4k-movies',
|
||||
name: intl.formatMessage(permissionMessages.request4kMovies),
|
||||
description: intl.formatMessage(
|
||||
permissionMessages.request4kMoviesDescription
|
||||
),
|
||||
permission: Permission.REQUEST_4K_MOVIE,
|
||||
},
|
||||
{
|
||||
id: 'request4k-tv',
|
||||
name: intl.formatMessage(permissionMessages.request4kTv),
|
||||
description: intl.formatMessage(
|
||||
permissionMessages.request4kTvDescription
|
||||
),
|
||||
permission: Permission.REQUEST_4K_TV,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'autoapprove',
|
||||
name: intl.formatMessage(permissionMessages.autoapprove),
|
||||
|
||||
@@ -6,6 +6,7 @@ import DiscordLogo from '../../assets/extlogos/discord_white.svg';
|
||||
import SlackLogo from '../../assets/extlogos/slack.svg';
|
||||
import TelegramLogo from '../../assets/extlogos/telegram.svg';
|
||||
import PushoverLogo from '../../assets/extlogos/pushover.svg';
|
||||
import Bolt from '../../assets/bolt.svg';
|
||||
|
||||
const messages = defineMessages({
|
||||
notificationsettings: 'Notification Settings',
|
||||
@@ -89,6 +90,17 @@ const settingsRoutes: SettingsRoute[] = [
|
||||
route: '/settings/notifications/pushover',
|
||||
regex: /^\/settings\/notifications\/pushover/,
|
||||
},
|
||||
{
|
||||
text: 'Webhook',
|
||||
content: (
|
||||
<span className="flex items-center">
|
||||
<Bolt className="h-4 mr-2" />
|
||||
Webhook
|
||||
</span>
|
||||
),
|
||||
route: '/settings/notifications/webhook',
|
||||
regex: /^\/settings\/notifications\/webhook/,
|
||||
},
|
||||
];
|
||||
|
||||
const SettingsNotifications: React.FC = ({ children }) => {
|
||||
|
||||
@@ -35,7 +35,6 @@ const messages = defineMessages({
|
||||
nodefault: 'No default server selected!',
|
||||
nodefaultdescription:
|
||||
'At least one server must be marked as default before any requests will make it to your services.',
|
||||
no4kimplemented: '(Default 4K servers are not currently implemented)',
|
||||
});
|
||||
|
||||
interface ServerInstanceProps {
|
||||
@@ -63,10 +62,10 @@ const ServerInstance: React.FC<ServerInstanceProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<li className="col-span-1 bg-gray-700 rounded-lg shadow">
|
||||
<div className="w-full flex items-center justify-between p-6 space-x-6">
|
||||
<div className="flex items-center justify-between w-full p-6 space-x-6">
|
||||
<div className="flex-1 truncate">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className="text-white text-sm leading-5 font-medium truncate">
|
||||
<div className="flex items-center mb-2 space-x-3">
|
||||
<h3 className="text-sm font-medium leading-5 text-white truncate">
|
||||
{name}
|
||||
</h3>
|
||||
{isDefault && (
|
||||
@@ -85,31 +84,31 @@ const ServerInstance: React.FC<ServerInstanceProps> = ({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-gray-300 text-sm leading-5 truncate">
|
||||
<span className="font-bold mr-2">
|
||||
<p className="mt-1 text-sm leading-5 text-gray-300 truncate">
|
||||
<span className="mr-2 font-bold">
|
||||
<FormattedMessage {...messages.address} />
|
||||
</span>
|
||||
{address}
|
||||
</p>
|
||||
<p className="mt-1 text-gray-300 text-sm leading-5 truncate">
|
||||
<span className="font-bold mr-2">
|
||||
<p className="mt-1 text-sm leading-5 text-gray-300 truncate">
|
||||
<span className="mr-2 font-bold">
|
||||
<FormattedMessage {...messages.activeProfile} />
|
||||
</span>{' '}
|
||||
{profileName}
|
||||
</p>
|
||||
</div>
|
||||
<img
|
||||
className="w-10 h-10 flex-shrink-0"
|
||||
className="flex-shrink-0 w-10 h-10"
|
||||
src={`/images/${isSonarr ? 'sonarr' : 'radarr'}_logo.png`}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-gray-800">
|
||||
<div className="-mt-px flex">
|
||||
<div className="w-0 flex-1 flex border-r border-gray-800">
|
||||
<div className="flex -mt-px">
|
||||
<div className="flex flex-1 w-0 border-r border-gray-800">
|
||||
<button
|
||||
onClick={() => onEdit()}
|
||||
className="relative -mr-px w-0 flex-1 inline-flex items-center justify-center py-4 text-sm leading-5 text-gray-200 font-medium border border-transparent rounded-bl-lg hover:text-white focus:outline-none focus:ring-blue focus:border-gray-500 focus:z-10 transition ease-in-out duration-150"
|
||||
className="relative inline-flex items-center justify-center flex-1 w-0 py-4 -mr-px text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out border border-transparent rounded-bl-lg hover:text-white focus:outline-none focus:ring-blue focus:border-gray-500 focus:z-10"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
@@ -124,10 +123,10 @@ const ServerInstance: React.FC<ServerInstanceProps> = ({
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="-ml-px w-0 flex-1 flex">
|
||||
<div className="flex flex-1 w-0 -ml-px">
|
||||
<button
|
||||
onClick={() => onDelete()}
|
||||
className="relative w-0 flex-1 inline-flex items-center justify-center py-4 text-sm leading-5 text-gray-200 font-medium border border-transparent rounded-br-lg hover:text-white focus:outline-none focus:ring-blue focus:border-gray-500 focus:z-10 transition ease-in-out duration-150"
|
||||
className="relative inline-flex items-center justify-center flex-1 w-0 py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out border border-transparent rounded-br-lg hover:text-white focus:outline-none focus:ring-blue focus:border-gray-500 focus:z-10"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
@@ -200,10 +199,10 @@ const SettingsServices: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-200">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-200">
|
||||
<FormattedMessage {...messages.radarrsettings} />
|
||||
</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-5 text-gray-500">
|
||||
<p className="max-w-2xl mt-1 text-sm leading-5 text-gray-500">
|
||||
<FormattedMessage {...messages.radarrSettingsDescription} />
|
||||
</p>
|
||||
</div>
|
||||
@@ -262,9 +261,6 @@ const SettingsServices: React.FC = () => {
|
||||
) && (
|
||||
<Alert title={intl.formatMessage(messages.nodefault)}>
|
||||
<p>{intl.formatMessage(messages.nodefaultdescription)}</p>
|
||||
<p className="mt-2">
|
||||
{intl.formatMessage(messages.no4kimplemented)}
|
||||
</p>
|
||||
</Alert>
|
||||
)}
|
||||
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -287,7 +283,7 @@ const SettingsServices: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
|
||||
<li className="h-32 col-span-1 border-2 border-gray-400 border-dashed rounded-lg shadow sm:h-32">
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
@@ -316,10 +312,10 @@ const SettingsServices: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-10">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-200">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-200">
|
||||
<FormattedMessage {...messages.sonarrsettings} />
|
||||
</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-5 text-gray-500">
|
||||
<p className="max-w-2xl mt-1 text-sm leading-5 text-gray-500">
|
||||
<FormattedMessage {...messages.sonarrSettingsDescription} />
|
||||
</p>
|
||||
</div>
|
||||
@@ -333,9 +329,6 @@ const SettingsServices: React.FC = () => {
|
||||
) && (
|
||||
<Alert title={intl.formatMessage(messages.nodefault)}>
|
||||
<p>{intl.formatMessage(messages.nodefaultdescription)}</p>
|
||||
<p className="mt-2">
|
||||
{intl.formatMessage(messages.no4kimplemented)}
|
||||
</p>
|
||||
</Alert>
|
||||
)}
|
||||
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -359,7 +352,7 @@ const SettingsServices: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
|
||||
<li className="h-32 col-span-1 border-2 border-gray-400 border-dashed rounded-lg shadow sm:h-32">
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
|
||||
@@ -295,7 +295,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
type="checkbox"
|
||||
id="isDefault"
|
||||
name="isDefault"
|
||||
className="form-checkbox rounded-md h-6 w-6 text-indigo-600 transition duration-150 ease-in-out"
|
||||
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -305,9 +305,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.servername)}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="name"
|
||||
name="name"
|
||||
@@ -319,11 +320,11 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
setIsValidated(false);
|
||||
setFieldValue('name', e.target.value);
|
||||
}}
|
||||
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
|
||||
/>
|
||||
</div>
|
||||
{errors.name && touched.name && (
|
||||
<div className="text-red-500 mt-2">{errors.name}</div>
|
||||
<div className="mt-2 text-red-500">{errors.name}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -333,10 +334,11 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.hostname)}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-500 bg-gray-600 text-gray-100 sm:text-sm cursor-default">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center px-3 text-gray-100 bg-gray-600 border border-r-0 border-gray-500 cursor-default rounded-l-md sm:text-sm">
|
||||
{values.ssl ? 'https://' : 'http://'}
|
||||
</span>
|
||||
<Field
|
||||
@@ -348,11 +350,11 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
setIsValidated(false);
|
||||
setFieldValue('hostname', e.target.value);
|
||||
}}
|
||||
className="flex-1 form-input block w-full min-w-0 rounded-r-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 form-input rounded-r-md sm:text-sm sm:leading-5"
|
||||
/>
|
||||
</div>
|
||||
{errors.hostname && touched.hostname && (
|
||||
<div className="text-red-500 mt-2">{errors.hostname}</div>
|
||||
<div className="mt-2 text-red-500">{errors.hostname}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -362,6 +364,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.port)}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<Field
|
||||
@@ -373,10 +376,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
setIsValidated(false);
|
||||
setFieldValue('port', e.target.value);
|
||||
}}
|
||||
className="rounded-md shadow-sm form-input block w-24 transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
className="block w-24 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md shadow-sm form-input sm:text-sm sm:leading-5"
|
||||
/>
|
||||
{errors.port && touched.port && (
|
||||
<div className="text-red-500 mt-2">{errors.port}</div>
|
||||
<div className="mt-2 text-red-500">{errors.port}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -396,7 +399,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
setIsValidated(false);
|
||||
setFieldValue('ssl', !values.ssl);
|
||||
}}
|
||||
className="form-checkbox rounded-md h-6 w-6 text-indigo-600 transition duration-150 ease-in-out"
|
||||
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -406,9 +409,10 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.apiKey)}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="apiKey"
|
||||
name="apiKey"
|
||||
@@ -420,11 +424,11 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
setIsValidated(false);
|
||||
setFieldValue('apiKey', e.target.value);
|
||||
}}
|
||||
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
|
||||
/>
|
||||
</div>
|
||||
{errors.apiKey && touched.apiKey && (
|
||||
<div className="text-red-500 mt-2">{errors.apiKey}</div>
|
||||
<div className="mt-2 text-red-500">{errors.apiKey}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -436,7 +440,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
{intl.formatMessage(messages.baseUrl)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="baseUrl"
|
||||
name="baseUrl"
|
||||
@@ -448,11 +452,11 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
setIsValidated(false);
|
||||
setFieldValue('baseUrl', e.target.value);
|
||||
}}
|
||||
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
|
||||
/>
|
||||
</div>
|
||||
{errors.baseUrl && touched.baseUrl && (
|
||||
<div className="text-red-500 mt-2">{errors.baseUrl}</div>
|
||||
<div className="mt-2 text-red-500">{errors.baseUrl}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -462,15 +466,16 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.qualityprofile)}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
as="select"
|
||||
id="activeProfileId"
|
||||
name="activeProfileId"
|
||||
disabled={!isValidated || isTesting}
|
||||
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
|
||||
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 bg-gray-700 border-gray-500 rounded-md form-select focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
|
||||
>
|
||||
<option value="">
|
||||
{isTesting
|
||||
@@ -493,7 +498,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
</Field>
|
||||
</div>
|
||||
{errors.activeProfileId && touched.activeProfileId && (
|
||||
<div className="text-red-500 mt-2">
|
||||
<div className="mt-2 text-red-500">
|
||||
{errors.activeProfileId}
|
||||
</div>
|
||||
)}
|
||||
@@ -505,15 +510,16 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.rootfolder)}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
as="select"
|
||||
id="rootFolder"
|
||||
name="rootFolder"
|
||||
disabled={!isValidated || isTesting}
|
||||
className="mt-1 form-select block rounded-md w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
|
||||
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 bg-gray-700 border-gray-500 rounded-md form-select focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
|
||||
>
|
||||
<option value="">
|
||||
{isTesting
|
||||
@@ -534,7 +540,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
</Field>
|
||||
</div>
|
||||
{errors.rootFolder && touched.rootFolder && (
|
||||
<div className="text-red-500 mt-2">
|
||||
<div className="mt-2 text-red-500">
|
||||
{errors.rootFolder}
|
||||
</div>
|
||||
)}
|
||||
@@ -548,13 +554,13 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
{intl.formatMessage(messages.animequalityprofile)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
as="select"
|
||||
id="activeAnimeProfileId"
|
||||
name="activeAnimeProfileId"
|
||||
disabled={!isValidated || isTesting}
|
||||
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
|
||||
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 bg-gray-700 border-gray-500 rounded-md form-select focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
|
||||
>
|
||||
<option value="">
|
||||
{isTesting
|
||||
@@ -578,7 +584,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
</div>
|
||||
{errors.activeAnimeProfileId &&
|
||||
touched.activeAnimeProfileId && (
|
||||
<div className="text-red-500 mt-2">
|
||||
<div className="mt-2 text-red-500">
|
||||
{errors.activeAnimeProfileId}
|
||||
</div>
|
||||
)}
|
||||
@@ -592,13 +598,13 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
{intl.formatMessage(messages.animerootfolder)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
as="select"
|
||||
id="activeAnimeRootFolder"
|
||||
name="activeAnimeRootFolder"
|
||||
disabled={!isValidated || isTesting}
|
||||
className="mt-1 form-select block rounded-md w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
|
||||
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 bg-gray-700 border-gray-500 rounded-md form-select focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
|
||||
>
|
||||
<option value="">
|
||||
{isTesting
|
||||
@@ -620,7 +626,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
</div>
|
||||
{errors.activeAnimeRootFolder &&
|
||||
touched.activeAnimeRootFolder && (
|
||||
<div className="text-red-500 mt-2">
|
||||
<div className="mt-2 text-red-500">
|
||||
{errors.rootFolder}
|
||||
</div>
|
||||
)}
|
||||
@@ -638,7 +644,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
type="checkbox"
|
||||
id="is4k"
|
||||
name="is4k"
|
||||
className="form-checkbox h-6 w-6 rounded-md text-indigo-600 transition duration-150 ease-in-out"
|
||||
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -654,7 +660,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
type="checkbox"
|
||||
id="enableSeasonFolders"
|
||||
name="enableSeasonFolders"
|
||||
className="form-checkbox h-6 w-6 rounded-md text-indigo-600 transition duration-150 ease-in-out"
|
||||
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,60 @@
|
||||
import React from 'react';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import Badge from '../Common/Badge';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
|
||||
const messages = defineMessages({
|
||||
status4k: '4K {status}',
|
||||
});
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status?: MediaStatus;
|
||||
is4k?: boolean;
|
||||
}
|
||||
|
||||
const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => {
|
||||
const StatusBadge: React.FC<StatusBadgeProps> = ({ status, is4k }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
if (is4k) {
|
||||
switch (status) {
|
||||
case MediaStatus.AVAILABLE:
|
||||
return (
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(messages.status4k, {
|
||||
status: intl.formatMessage(globalMessages.available),
|
||||
})}
|
||||
</Badge>
|
||||
);
|
||||
case MediaStatus.PARTIALLY_AVAILABLE:
|
||||
return (
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(messages.status4k, {
|
||||
status: intl.formatMessage(globalMessages.partiallyavailable),
|
||||
})}
|
||||
</Badge>
|
||||
);
|
||||
case MediaStatus.PROCESSING:
|
||||
return (
|
||||
<Badge badgeType="primary">
|
||||
{intl.formatMessage(messages.status4k, {
|
||||
status: intl.formatMessage(globalMessages.requested),
|
||||
})}
|
||||
</Badge>
|
||||
);
|
||||
case MediaStatus.PENDING:
|
||||
return (
|
||||
<Badge badgeType="warning">
|
||||
{intl.formatMessage(messages.status4k, {
|
||||
status: intl.formatMessage(globalMessages.pending),
|
||||
})}
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case MediaStatus.AVAILABLE:
|
||||
return (
|
||||
|
||||
@@ -8,10 +8,8 @@ import {
|
||||
import useSWR from 'swr';
|
||||
import { useRouter } from 'next/router';
|
||||
import Button from '../Common/Button';
|
||||
import type { TvResult } from '../../../server/models/Search';
|
||||
import Link from 'next/link';
|
||||
import Slider from '../Slider';
|
||||
import TitleCard from '../TitleCard';
|
||||
import PersonCard from '../PersonCard';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
@@ -19,7 +17,6 @@ import { useUser, Permission } from '../../hooks/useUser';
|
||||
import { TvDetails as TvDetailsType } from '../../../server/models/Tv';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import RequestModal from '../RequestModal';
|
||||
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
|
||||
import axios from 'axios';
|
||||
import SlideOver from '../Common/SlideOver';
|
||||
import RequestBlock from '../RequestBlock';
|
||||
@@ -36,6 +33,8 @@ import ExternalLinkBlock from '../ExternalLinkBlock';
|
||||
import { sortCrewPriority } from '../../utils/creditHelpers';
|
||||
import { Crew } from '../../../server/models/common';
|
||||
import StatusBadge from '../StatusBadge';
|
||||
import RequestButton from '../RequestButton';
|
||||
import MediaSlider from '../MediaSlider';
|
||||
|
||||
const messages = defineMessages({
|
||||
firstAirDate: 'First Air Date',
|
||||
@@ -50,14 +49,8 @@ const messages = defineMessages({
|
||||
watchtrailer: 'Watch Trailer',
|
||||
available: 'Available',
|
||||
unavailable: 'Unavailable',
|
||||
request: 'Request',
|
||||
requestmore: 'Request More',
|
||||
pending: 'Pending',
|
||||
overviewunavailable: 'Overview unavailable',
|
||||
approverequests:
|
||||
'Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}',
|
||||
declinerequests:
|
||||
'Decline {requestCount} {requestCount, plural, one {Request} other {Requests}}',
|
||||
manageModalTitle: 'Manage Series',
|
||||
manageModalRequests: 'Requests',
|
||||
manageModalNoRequests: 'No Requests',
|
||||
@@ -76,20 +69,6 @@ interface TvDetailsProps {
|
||||
tv?: TvDetailsType;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: TvResult[];
|
||||
}
|
||||
|
||||
enum MediaRequestStatus {
|
||||
PENDING = 1,
|
||||
APPROVED,
|
||||
DECLINED,
|
||||
AVAILABLE,
|
||||
}
|
||||
|
||||
const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
const { hasPermission } = useUser();
|
||||
const router = useRouter();
|
||||
@@ -103,12 +82,6 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
initialData: tv,
|
||||
}
|
||||
);
|
||||
const { data: recommended, error: recommendedError } = useSWR<SearchResult>(
|
||||
`/api/v1/tv/${router.query.tvId}/recommendations?language=${locale}`
|
||||
);
|
||||
const { data: similar, error: similarError } = useSWR<SearchResult>(
|
||||
`/api/v1/tv/${router.query.tvId}/similar?language=${locale}`
|
||||
);
|
||||
|
||||
const { data: ratingData } = useSWR<RTRating>(
|
||||
`/api/v1/tv/${router.query.tvId}/ratings`
|
||||
@@ -126,29 +99,11 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
return <Error statusCode={404} />;
|
||||
}
|
||||
|
||||
const activeRequests = data.mediaInfo?.requests?.filter(
|
||||
(request) => request.status === MediaRequestStatus.PENDING
|
||||
);
|
||||
|
||||
const trailerUrl = data.relatedVideos
|
||||
?.filter((r) => r.type === 'Trailer')
|
||||
.sort((a, b) => a.size - b.size)
|
||||
.pop()?.url;
|
||||
|
||||
const modifyRequests = async (type: 'approve' | 'decline'): Promise<void> => {
|
||||
if (!activeRequests) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
activeRequests.map(async (request) => {
|
||||
return axios.get(`/api/v1/request/${request.id}/${type}`);
|
||||
})
|
||||
);
|
||||
|
||||
revalidate();
|
||||
};
|
||||
|
||||
const deleteMedia = async () => {
|
||||
if (data?.mediaInfo?.id) {
|
||||
await axios.delete(`/api/v1/media/${data?.mediaInfo?.id}`);
|
||||
@@ -164,6 +119,14 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
const is4kComplete =
|
||||
data.seasons.filter((season) => season.seasonNumber !== 0).length <=
|
||||
(
|
||||
data.mediaInfo?.seasons.filter(
|
||||
(season) => season.status4k === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover"
|
||||
@@ -236,7 +199,14 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left">
|
||||
<div className="mb-2">
|
||||
<StatusBadge status={data.mediaInfo?.status} />
|
||||
{data.mediaInfo && data.mediaInfo.status !== MediaStatus.UNKNOWN && (
|
||||
<span className="mr-2">
|
||||
<StatusBadge status={data.mediaInfo?.status} />
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
<StatusBadge status={data.mediaInfo?.status4k} is4k />
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl lg:text-4xl">
|
||||
<span>{data.name}</span>
|
||||
@@ -250,9 +220,14 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
{data.genres.map((g) => g.name).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-end flex-shrink-0 mt-4 lg:mt-0">
|
||||
<div className="flex flex-wrap justify-center flex-shrink-0 mt-4 sm:flex-nowrap sm:justify-end lg:mt-0">
|
||||
{trailerUrl && (
|
||||
<a href={trailerUrl} target="_blank" rel="noreferrer">
|
||||
<a
|
||||
href={trailerUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mb-3 sm:mb-0"
|
||||
>
|
||||
<Button buttonType="ghost">
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
@@ -278,120 +253,20 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{(!data.mediaInfo ||
|
||||
data.mediaInfo.status === MediaStatus.UNKNOWN) && (
|
||||
<Button
|
||||
className="ml-2"
|
||||
buttonType="primary"
|
||||
onClick={() => setShowRequestModal(true)}
|
||||
>
|
||||
<svg
|
||||
className="w-5 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedMessage {...messages.request} />
|
||||
</Button>
|
||||
)}
|
||||
{data.mediaInfo &&
|
||||
data.mediaInfo.status !== MediaStatus.UNKNOWN &&
|
||||
!isComplete && (
|
||||
<ButtonWithDropdown
|
||||
dropdownIcon={
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
text={
|
||||
<>
|
||||
<svg
|
||||
className="w-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedMessage {...messages.requestmore} />
|
||||
</>
|
||||
}
|
||||
className="ml-2"
|
||||
onClick={() => setShowRequestModal(true)}
|
||||
>
|
||||
{hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
activeRequests &&
|
||||
activeRequests.length > 0 && (
|
||||
<>
|
||||
<ButtonWithDropdown.Item
|
||||
onClick={() => modifyRequests('approve')}
|
||||
>
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedMessage
|
||||
{...messages.approverequests}
|
||||
values={{ requestCount: activeRequests.length }}
|
||||
/>
|
||||
</ButtonWithDropdown.Item>
|
||||
<ButtonWithDropdown.Item
|
||||
onClick={() => modifyRequests('decline')}
|
||||
>
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedMessage
|
||||
{...messages.declinerequests}
|
||||
values={{ requestCount: activeRequests.length }}
|
||||
/>
|
||||
</ButtonWithDropdown.Item>
|
||||
</>
|
||||
)}
|
||||
</ButtonWithDropdown>
|
||||
)}
|
||||
<div className="mb-3 sm:mb-0">
|
||||
<RequestButton
|
||||
mediaType="tv"
|
||||
onUpdate={() => revalidate()}
|
||||
tmdbId={data?.id}
|
||||
media={data?.mediaInfo}
|
||||
isShowComplete={isComplete}
|
||||
is4kShowComplete={is4kComplete}
|
||||
/>
|
||||
</div>
|
||||
{hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<Button
|
||||
buttonType="default"
|
||||
className="ml-2 first:ml-0"
|
||||
className="mb-3 ml-2 first:ml-0 sm:mb-0"
|
||||
onClick={() => setShowManager(true)}
|
||||
>
|
||||
<svg
|
||||
@@ -639,103 +514,20 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
{(recommended?.results ?? []).length > 0 && (
|
||||
<>
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
href="/tv/[tvId]/recommendations"
|
||||
as={`/tv/${data.id}/recommendations`}
|
||||
>
|
||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>
|
||||
<FormattedMessage {...messages.recommendations} />
|
||||
</span>
|
||||
<svg
|
||||
className="w-6 h-6 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="recommendations"
|
||||
isLoading={!recommended && !recommendedError}
|
||||
isEmpty={false}
|
||||
items={recommended?.results.map((title) => (
|
||||
<TitleCard
|
||||
key={`recommended-${title.id}`}
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
title={title.name}
|
||||
userScore={title.voteAverage}
|
||||
year={title.firstAirDate}
|
||||
mediaType={title.mediaType}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(similar?.results ?? []).length > 0 && (
|
||||
<>
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link href="/tv/[tvId]/similar" as={`/tv/${data.id}/similar`}>
|
||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>
|
||||
<FormattedMessage {...messages.similar} />
|
||||
</span>
|
||||
<svg
|
||||
className="w-6 h-6 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="similar"
|
||||
isLoading={!similar && !similarError}
|
||||
isEmpty={false}
|
||||
items={similar?.results.map((title) => (
|
||||
<TitleCard
|
||||
key={`recommended-${title.id}`}
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
title={title.name}
|
||||
userScore={title.voteAverage}
|
||||
year={title.firstAirDate}
|
||||
mediaType={title.mediaType}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<MediaSlider
|
||||
sliderKey="recommendations"
|
||||
title={intl.formatMessage(messages.recommendations)}
|
||||
url={`/api/v1/tv/${router.query.tvId}/recommendations`}
|
||||
linkUrl={`/tv/${data.id}/recommendations`}
|
||||
hideWhenEmpty
|
||||
/>
|
||||
<MediaSlider
|
||||
sliderKey="similar"
|
||||
title={intl.formatMessage(messages.similar)}
|
||||
url={`/api/v1/tv/${router.query.tvId}/similar`}
|
||||
linkUrl={`/tv/${data.id}/similar`}
|
||||
hideWhenEmpty
|
||||
/>
|
||||
<div className="pb-8" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -41,6 +41,15 @@ export const messages = defineMessages({
|
||||
autoapproveSeries: 'Auto Approve Series',
|
||||
autoapproveSeriesDescription:
|
||||
'Grants auto approve for series requests made by this user.',
|
||||
request4k: 'Request 4K',
|
||||
request4kDescription: 'Grants permission to request 4K movies and series.',
|
||||
request4kMovies: 'Request 4K Movies',
|
||||
request4kMoviesDescription: 'Grants permission to request 4K movies.',
|
||||
request4kTv: 'Request 4K Series',
|
||||
request4kTvDescription: 'Grants permission to request 4K Series.',
|
||||
advancedrequest: 'Advanced Requests',
|
||||
advancedrequestDescription:
|
||||
'Grants permission to use advanced request options. (Ex. Changing servers/profiles/paths)',
|
||||
save: 'Save',
|
||||
saving: 'Saving...',
|
||||
usersaved: 'User saved',
|
||||
@@ -73,7 +82,6 @@ const UserEdit: React.FC = () => {
|
||||
await axios.put(`/api/v1/user/${user?.id}`, {
|
||||
permissions: currentPermission,
|
||||
email: user?.email,
|
||||
avatar: user?.avatar,
|
||||
});
|
||||
|
||||
addToast(intl.formatMessage(messages.usersaved), {
|
||||
@@ -120,6 +128,14 @@ const UserEdit: React.FC = () => {
|
||||
name: intl.formatMessage(messages.managerequests),
|
||||
description: intl.formatMessage(messages.managerequestsDescription),
|
||||
permission: Permission.MANAGE_REQUESTS,
|
||||
children: [
|
||||
{
|
||||
id: 'advancedrequest',
|
||||
name: intl.formatMessage(messages.advancedrequest),
|
||||
description: intl.formatMessage(messages.advancedrequestDescription),
|
||||
permission: Permission.REQUEST_ADVANCED,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'request',
|
||||
@@ -127,6 +143,26 @@ const UserEdit: React.FC = () => {
|
||||
description: intl.formatMessage(messages.requestDescription),
|
||||
permission: Permission.REQUEST,
|
||||
},
|
||||
{
|
||||
id: 'request4k',
|
||||
name: intl.formatMessage(messages.request4k),
|
||||
description: intl.formatMessage(messages.request4kDescription),
|
||||
permission: Permission.REQUEST_4K,
|
||||
children: [
|
||||
{
|
||||
id: 'request4k-movies',
|
||||
name: intl.formatMessage(messages.request4kMovies),
|
||||
description: intl.formatMessage(messages.request4kMoviesDescription),
|
||||
permission: Permission.REQUEST_4K_MOVIE,
|
||||
},
|
||||
{
|
||||
id: 'request4k-tv',
|
||||
name: intl.formatMessage(messages.request4kTv),
|
||||
description: intl.formatMessage(messages.request4kTvDescription),
|
||||
permission: Permission.REQUEST_4K_TV,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'autoapprove',
|
||||
name: intl.formatMessage(messages.autoapprove),
|
||||
|
||||
@@ -6,7 +6,7 @@ import Badge from '../Common/Badge';
|
||||
import { FormattedDate, defineMessages, useIntl } from 'react-intl';
|
||||
import Button from '../Common/Button';
|
||||
import { hasPermission } from '../../../server/lib/permissions';
|
||||
import { Permission } from '../../hooks/useUser';
|
||||
import { Permission, UserType } from '../../hooks/useUser';
|
||||
import { useRouter } from 'next/router';
|
||||
import Header from '../Common/Header';
|
||||
import Table from '../Common/Table';
|
||||
@@ -15,6 +15,10 @@ import Modal from '../Common/Modal';
|
||||
import axios from 'axios';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import AddUserIcon from '../../assets/useradd.svg';
|
||||
import Alert from '../Common/Alert';
|
||||
|
||||
const messages = defineMessages({
|
||||
userlist: 'User List',
|
||||
@@ -38,6 +42,22 @@ const messages = defineMessages({
|
||||
userdeleteerror: 'Something went wrong deleting the user',
|
||||
deleteconfirm:
|
||||
'Are you sure you want to delete this user? All existing request data from this user will be removed.',
|
||||
localuser: 'Local User',
|
||||
createlocaluser: 'Create Local User',
|
||||
createuser: 'Create User',
|
||||
creating: 'Creating',
|
||||
create: 'Create',
|
||||
validationemailrequired: 'Must enter a valid email address.',
|
||||
validationpasswordminchars:
|
||||
'Password is too short - should be 8 chars minimum.',
|
||||
usercreatedfailed: 'Something went wrong when trying to create the user',
|
||||
usercreatedsuccess: 'Successfully created the user',
|
||||
email: 'Email Address',
|
||||
password: 'Password',
|
||||
passwordinfo: 'Password Info',
|
||||
passwordinfodescription:
|
||||
'Email notification settings need to be enabled and setup in order to use the auto generated passwords',
|
||||
autogeneratepassword: 'Automatically generate password',
|
||||
});
|
||||
|
||||
const UserList: React.FC = () => {
|
||||
@@ -53,6 +73,11 @@ const UserList: React.FC = () => {
|
||||
}>({
|
||||
isOpen: false,
|
||||
});
|
||||
const [createModal, setCreateModal] = useState<{
|
||||
isOpen: boolean;
|
||||
}>({
|
||||
isOpen: false,
|
||||
});
|
||||
|
||||
const deleteUser = async () => {
|
||||
setDeleting(true);
|
||||
@@ -107,6 +132,15 @@ const UserList: React.FC = () => {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
const CreateUserSchema = Yup.object().shape({
|
||||
email: Yup.string()
|
||||
.email()
|
||||
.required(intl.formatMessage(messages.validationemailrequired)),
|
||||
password: Yup.lazy((value) =>
|
||||
!value ? Yup.string() : Yup.string().min(8)
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Transition
|
||||
@@ -149,16 +183,155 @@ const UserList: React.FC = () => {
|
||||
{intl.formatMessage(messages.deleteconfirm)}
|
||||
</Modal>
|
||||
</Transition>
|
||||
<div className="flex items-center justify-between">
|
||||
<Header>{intl.formatMessage(messages.userlist)}</Header>
|
||||
<Button
|
||||
className="mx-4 my-8"
|
||||
buttonType="primary"
|
||||
disabled={isImporting}
|
||||
onClick={() => importFromPlex()}
|
||||
<Transition
|
||||
enter="opacity-0 transition duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="opacity-100 transition duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={createModal.isOpen}
|
||||
>
|
||||
<Formik
|
||||
initialValues={{
|
||||
email: '',
|
||||
password: '',
|
||||
genpassword: true,
|
||||
}}
|
||||
validationSchema={CreateUserSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await axios.post('/api/v1/user', {
|
||||
email: values.email,
|
||||
password: values.genpassword ? null : values.password,
|
||||
});
|
||||
addToast(intl.formatMessage(messages.usercreatedsuccess), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
setCreateModal({ isOpen: false });
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.usercreatedfailed), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} finally {
|
||||
revalidate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.importfromplex)}
|
||||
</Button>
|
||||
{({
|
||||
errors,
|
||||
touched,
|
||||
isSubmitting,
|
||||
values,
|
||||
isValid,
|
||||
setFieldValue,
|
||||
handleSubmit,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title={intl.formatMessage(messages.createuser)}
|
||||
iconSvg={<AddUserIcon className="h-6" />}
|
||||
onOk={() => handleSubmit()}
|
||||
okText={
|
||||
isSubmitting
|
||||
? intl.formatMessage(messages.creating)
|
||||
: intl.formatMessage(messages.create)
|
||||
}
|
||||
okDisabled={isSubmitting || !isValid}
|
||||
okButtonType="primary"
|
||||
onCancel={() => setCreateModal({ isOpen: false })}
|
||||
>
|
||||
<Alert title={intl.formatMessage(messages.passwordinfo)}>
|
||||
{intl.formatMessage(messages.passwordinfodescription)}
|
||||
</Alert>
|
||||
<Form>
|
||||
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.email)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="email"
|
||||
name="email"
|
||||
type="text"
|
||||
placeholder="name@example.com"
|
||||
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && touched.email && (
|
||||
<div className="mt-2 text-red-500">{errors.email}</div>
|
||||
)}
|
||||
</div>
|
||||
<label
|
||||
htmlFor="genpassword"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.autogeneratepassword)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="genpassword"
|
||||
name="genpassword"
|
||||
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
|
||||
onClick={() => setFieldValue('password', '')}
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.password)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
disabled={values.genpassword}
|
||||
placeholder={intl.formatMessage(messages.password)}
|
||||
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
|
||||
/>
|
||||
</div>
|
||||
{errors.password && touched.password && (
|
||||
<div className="mt-2 text-red-500">
|
||||
{errors.password}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
</Transition>
|
||||
<div className="flex flex-col justify-between sm:flex-row">
|
||||
<Header>{intl.formatMessage(messages.userlist)}</Header>
|
||||
<div className="flex">
|
||||
<Button
|
||||
className="mx-4 my-8 outline"
|
||||
buttonType="primary"
|
||||
onClick={() => setCreateModal({ isOpen: true })}
|
||||
>
|
||||
{intl.formatMessage(messages.createlocaluser)}
|
||||
</Button>
|
||||
<Button
|
||||
className="mx-4 my-8"
|
||||
buttonType="primary"
|
||||
disabled={isImporting}
|
||||
onClick={() => importFromPlex()}
|
||||
>
|
||||
{intl.formatMessage(messages.importfromplex)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table>
|
||||
<thead>
|
||||
@@ -188,7 +361,7 @@ const UserList: React.FC = () => {
|
||||
<div className="text-sm font-medium leading-5">
|
||||
{user.username}
|
||||
</div>
|
||||
<div className="text-sm text-gray-300 leading-5">
|
||||
<div className="text-sm leading-5 text-gray-300">
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,9 +371,15 @@ const UserList: React.FC = () => {
|
||||
<div className="text-sm leading-5">{user.requestCount}</div>
|
||||
</Table.TD>
|
||||
<Table.TD>
|
||||
<Badge badgeType="warning">
|
||||
{intl.formatMessage(messages.plexuser)}
|
||||
</Badge>
|
||||
{user.userType === UserType.PLEX ? (
|
||||
<Badge badgeType="warning">
|
||||
{intl.formatMessage(messages.plexuser)}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge badgeType="default">
|
||||
{intl.formatMessage(messages.localuser)}
|
||||
</Badge>
|
||||
)}
|
||||
</Table.TD>
|
||||
<Table.TD>
|
||||
{hasPermission(Permission.ADMIN, user.permissions)
|
||||
|
||||
@@ -12,7 +12,8 @@ export type AvailableLocales =
|
||||
| 'it'
|
||||
| 'pt-BR'
|
||||
| 'sr'
|
||||
| 'sv';
|
||||
| 'sv'
|
||||
| 'zh-Hant';
|
||||
|
||||
interface LanguageContextProps {
|
||||
locale: AvailableLocales;
|
||||
|
||||
39
src/context/SettingsContext.tsx
Normal file
39
src/context/SettingsContext.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { PublicSettingsResponse } from '../../server/interfaces/api/settingsInterfaces';
|
||||
import useSWR from 'swr';
|
||||
|
||||
interface SettingsContextProps {
|
||||
currentSettings: PublicSettingsResponse;
|
||||
}
|
||||
|
||||
const defaultSettings = {
|
||||
initialized: false,
|
||||
movie4kEnabled: false,
|
||||
series4kEnabled: false,
|
||||
};
|
||||
|
||||
export const SettingsContext = React.createContext<SettingsContextProps>({
|
||||
currentSettings: defaultSettings,
|
||||
});
|
||||
|
||||
export const SettingsProvider: React.FC<SettingsContextProps> = ({
|
||||
children,
|
||||
currentSettings,
|
||||
}) => {
|
||||
const { data, error } = useSWR<PublicSettingsResponse>(
|
||||
'/api/v1/settings/public',
|
||||
{ initialData: currentSettings }
|
||||
);
|
||||
|
||||
let newSettings = defaultSettings;
|
||||
|
||||
if (data && !error) {
|
||||
newSettings = data;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={{ currentSettings: newSettings }}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
46
src/hooks/useRequestOverride.ts
Normal file
46
src/hooks/useRequestOverride.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import useSWR from 'swr';
|
||||
import { MediaRequest } from '../../server/entity/MediaRequest';
|
||||
import { ServiceCommonServer } from '../../server/interfaces/api/serviceInterfaces';
|
||||
|
||||
interface OverrideStatus {
|
||||
server: string | null;
|
||||
profile: number | null;
|
||||
rootFolder: string | null;
|
||||
}
|
||||
|
||||
const useRequestOverride = (request: MediaRequest): OverrideStatus => {
|
||||
const { data } = useSWR<ServiceCommonServer[]>(
|
||||
`/api/v1/service/${request.type === 'movie' ? 'radarr' : 'sonarr'}`
|
||||
);
|
||||
|
||||
if (!data) {
|
||||
return {
|
||||
server: null,
|
||||
profile: null,
|
||||
rootFolder: null,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultServer = data.find(
|
||||
(server) => server.is4k === request.is4k && server.isDefault
|
||||
);
|
||||
|
||||
const activeServer = data.find((server) => server.id === request.serverId);
|
||||
|
||||
return {
|
||||
server:
|
||||
activeServer && request.serverId !== defaultServer?.id
|
||||
? activeServer.name
|
||||
: null,
|
||||
profile:
|
||||
defaultServer?.activeProfileId !== request.profileId
|
||||
? request.profileId
|
||||
: null,
|
||||
rootFolder:
|
||||
defaultServer?.activeDirectory !== request.rootFolder
|
||||
? request.rootFolder
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
export default useRequestOverride;
|
||||
@@ -1,5 +1,6 @@
|
||||
import useSwr from 'swr';
|
||||
import { hasPermission, Permission } from '../../server/lib/permissions';
|
||||
import { UserType } from '../../server/constants/user';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
@@ -7,9 +8,10 @@ export interface User {
|
||||
email: string;
|
||||
avatar: string;
|
||||
permissions: number;
|
||||
userType: number;
|
||||
}
|
||||
|
||||
export { Permission };
|
||||
export { Permission, UserType };
|
||||
|
||||
interface UserHookResponse {
|
||||
user?: User;
|
||||
|
||||
@@ -20,6 +20,7 @@ const globalMessages = defineMessages({
|
||||
retry: 'Retry',
|
||||
deleting: 'Deleting…',
|
||||
close: 'Close',
|
||||
edit: 'Edit',
|
||||
});
|
||||
|
||||
export default globalMessages;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user