Compare commits
189 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
077513151e | ||
|
|
191f39e8bd | ||
|
|
fb883e8043 | ||
|
|
4435468561 | ||
|
|
e821d0a02b | ||
|
|
5f71fb7ee2 | ||
|
|
a0c5608aa0 | ||
|
|
816fec1a83 | ||
|
|
2a4dd52275 | ||
|
|
3601d442db | ||
|
|
53bede692d | ||
|
|
fe46895289 | ||
|
|
a0024a0cbe | ||
|
|
8408e19568 | ||
|
|
ed49e7c28e | ||
|
|
549567a7e9 | ||
|
|
51f99a2e7b | ||
|
|
048b6c1d90 | ||
|
|
a6a065ac0b | ||
|
|
5e5ba40505 | ||
|
|
abb0b2d763 | ||
|
|
d2668d3f49 | ||
|
|
36b2b4fba4 | ||
|
|
6beac736ef | ||
|
|
6933b661ca | ||
|
|
4b6ad8a387 | ||
|
|
992a23222a | ||
|
|
e75b592bb2 | ||
|
|
baa62b286d | ||
|
|
908f63557c | ||
|
|
f88c4a6d4a | ||
|
|
0972f40a4e | ||
|
|
1f0486eba2 | ||
|
|
086183b563 | ||
|
|
df4ef1fbe5 | ||
|
|
727fa06c18 | ||
|
|
bee6eb288f | ||
|
|
3473aed27e | ||
|
|
a9144a2167 | ||
|
|
59822d6e42 | ||
|
|
ee84f74f8a | ||
|
|
18925decaf | ||
|
|
67146c33ef | ||
|
|
d5eb4d8d43 | ||
|
|
fc12ab84d9 | ||
|
|
0d088e085e | ||
|
|
2fe53ec5a8 | ||
|
|
ce0266f74e | ||
|
|
c21fa5b535 | ||
|
|
647d5efb09 | ||
|
|
bb89f679b6 | ||
|
|
3210ac29d0 | ||
|
|
e9c899ce41 | ||
|
|
0bd1bee1fa | ||
|
|
46326f9d16 | ||
|
|
ad18a40969 | ||
|
|
ae2ede8952 | ||
|
|
675060bcdf | ||
|
|
f8f388db6d | ||
|
|
2948f9360e | ||
|
|
df4ac8361f | ||
|
|
92a874e36d | ||
|
|
6d7907e844 | ||
|
|
d9788c4aa9 | ||
|
|
db0a5c44f6 | ||
|
|
c545ec16e7 | ||
|
|
961d110720 | ||
|
|
e8776fd336 | ||
|
|
7c08809501 | ||
|
|
7fd43c9529 | ||
|
|
33f8831e88 | ||
|
|
20b119c1ac | ||
|
|
6cd20491d2 | ||
|
|
cb8a50bd66 | ||
|
|
fddbb3cdfe | ||
|
|
01c179f762 | ||
|
|
e08fa35548 | ||
|
|
d96d65b8af | ||
|
|
54b96cc451 | ||
|
|
7d2a187865 | ||
|
|
900827be97 | ||
|
|
b9faa6486b | ||
|
|
3f2a04c881 | ||
|
|
ff618956b5 | ||
|
|
b5988f9a5f | ||
|
|
3ba09d07eb | ||
|
|
f998873fc5 | ||
|
|
33b54c08b5 | ||
|
|
34136ef617 | ||
|
|
17df5dd072 | ||
|
|
4b48a36ad3 | ||
|
|
2d3359a7e3 | ||
|
|
6d08b10820 | ||
|
|
78af1a3e6d | ||
|
|
16bd3e9fd5 | ||
|
|
6360c82898 | ||
|
|
67a8f7fbfe | ||
|
|
e17c637483 | ||
|
|
15013d6c5d | ||
|
|
8cb05c413a | ||
|
|
2098a2d3d2 | ||
|
|
6c1ee830a1 | ||
|
|
0658b7943e | ||
|
|
f1df9eeb2f | ||
|
|
edbbccf3ae | ||
|
|
a3973791fb | ||
|
|
cb5c0403c1 | ||
|
|
7bf924f7e9 | ||
|
|
c53dc3b15d | ||
|
|
b96bd33982 | ||
|
|
92c9001c9d | ||
|
|
157ab80be1 | ||
|
|
ac0f95b773 | ||
|
|
5f09e83ed8 | ||
|
|
27349ec953 | ||
|
|
2ce6e0a94a | ||
|
|
da65988926 | ||
|
|
b3b8e7b0cc | ||
|
|
c8f1756a2b | ||
|
|
f943701e13 | ||
|
|
7bf9addd13 | ||
|
|
93983c976b | ||
|
|
05a2fddf4a | ||
|
|
7f2be51a7e | ||
|
|
de01e13dda | ||
|
|
beca52715a | ||
|
|
61ed508a56 | ||
|
|
ce9d9fdf13 | ||
|
|
e32b585e97 | ||
|
|
6d2abfa863 | ||
|
|
d02724770d | ||
|
|
5fa37b5913 | ||
|
|
5155a4caf2 | ||
|
|
42381ed5cb | ||
|
|
b99f9e6888 | ||
|
|
e6c5d6ff7c | ||
|
|
4f9514885b | ||
|
|
52e695bd08 | ||
|
|
051f1b3e89 | ||
|
|
4392a881aa | ||
|
|
0949c9b334 | ||
|
|
30d04ce35a | ||
|
|
83efb0e3d4 | ||
|
|
08e968fd00 | ||
|
|
0d8e0d0352 | ||
|
|
1ab3a4b80a | ||
|
|
b6e60a412b | ||
|
|
d4cc727d52 | ||
|
|
45ebfb937e | ||
|
|
85325cda7f | ||
|
|
3fa7ff9858 | ||
|
|
b16fbafa1f | ||
|
|
ccb98553f1 | ||
|
|
d8060afe02 | ||
|
|
6502feb1a5 | ||
|
|
8cd067b6e9 | ||
|
|
d6eb3ae64e | ||
|
|
02d6b46448 | ||
|
|
df94db050b | ||
|
|
8d8e750982 | ||
|
|
6c7f1cad8b | ||
|
|
74c36ac31d | ||
|
|
385fb23b72 | ||
|
|
adc07a9624 | ||
|
|
335377e250 | ||
|
|
383c79c19a | ||
|
|
44dbb745b6 | ||
|
|
30a8934626 | ||
|
|
2d0afb29d3 | ||
|
|
86cadb8283 | ||
|
|
a494507dfe | ||
|
|
887f5dd487 | ||
|
|
8da357aea0 | ||
|
|
a304742828 | ||
|
|
a14af7c21c | ||
|
|
ff5106bf42 | ||
|
|
42788adb75 | ||
|
|
5774445dd7 | ||
|
|
dc7d1aabb8 | ||
|
|
b22eb5a49a | ||
|
|
d38b28d206 | ||
|
|
b0c663bacc | ||
|
|
5ff8ae32ec | ||
|
|
29e932252f | ||
|
|
a0e594a292 | ||
|
|
96f3861c90 | ||
|
|
77f3f99fbb | ||
|
|
e2b99b68e3 | ||
|
|
86addbc4dd |
183
.all-contributorsrc
Normal file
183
.all-contributorsrc
Normal file
@@ -0,0 +1,183 @@
|
||||
{
|
||||
"files": [
|
||||
"README.md"
|
||||
],
|
||||
"imageSize": 100,
|
||||
"commit": false,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "sct",
|
||||
"name": "sct",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/234213?v=4",
|
||||
"profile": "https://sct.dev",
|
||||
"contributions": [
|
||||
"code",
|
||||
"design",
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "azoitos",
|
||||
"name": "Alex Zoitos",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/26529049?v=4",
|
||||
"profile": "https://github.com/azoitos",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "OwsleyJr",
|
||||
"name": "Brandon Cohen",
|
||||
"avatar_url": "https://avatars3.githubusercontent.com/u/8635678?v=4",
|
||||
"profile": "https://github.com/OwsleyJr",
|
||||
"contributions": [
|
||||
"code",
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Ahreluth",
|
||||
"name": "Ahreluth",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/75682440?v=4",
|
||||
"profile": "https://github.com/Ahreluth",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "KovalevArtem",
|
||||
"name": "KovalevArtem",
|
||||
"avatar_url": "https://avatars0.githubusercontent.com/u/36500228?v=4",
|
||||
"profile": "https://github.com/KovalevArtem",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "GiyomuWeb",
|
||||
"name": "GiyomuWeb",
|
||||
"avatar_url": "https://avatars0.githubusercontent.com/u/62489209?v=4",
|
||||
"profile": "https://github.com/GiyomuWeb",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "angrycuban13",
|
||||
"name": "Angry Cuban",
|
||||
"avatar_url": "https://avatars3.githubusercontent.com/u/39564898?v=4",
|
||||
"profile": "https://github.com/angrycuban13",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "jvennik",
|
||||
"name": "jvennik",
|
||||
"avatar_url": "https://avatars3.githubusercontent.com/u/6672637?v=4",
|
||||
"profile": "https://github.com/jvennik",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "darknessgp",
|
||||
"name": "darknessgp",
|
||||
"avatar_url": "https://avatars0.githubusercontent.com/u/1521243?v=4",
|
||||
"profile": "https://github.com/darknessgp",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "saltydk",
|
||||
"name": "salty",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/6587950?v=4",
|
||||
"profile": "https://github.com/saltydk",
|
||||
"contributions": [
|
||||
"infra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Shutruk",
|
||||
"name": "Shutruk",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/9198633?v=4",
|
||||
"profile": "https://github.com/Shutruk",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "krystiancharubin",
|
||||
"name": "Krystian Charubin",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/17775600?v=4",
|
||||
"profile": "https://github.com/krystiancharubin",
|
||||
"contributions": [
|
||||
"design"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "kieron",
|
||||
"name": "Kieron Boswell",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/8655212?v=4",
|
||||
"profile": "https://github.com/kieron",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "samwiseg0",
|
||||
"name": "samwiseg0",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/2241731?v=4",
|
||||
"profile": "https://github.com/samwiseg0",
|
||||
"contributions": [
|
||||
"question",
|
||||
"infra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "ecelebi29",
|
||||
"name": "ecelebi29",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/8337120?v=4",
|
||||
"profile": "https://github.com/ecelebi29",
|
||||
"contributions": [
|
||||
"code",
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "mmozeiko",
|
||||
"name": "Mārtiņš Možeiko",
|
||||
"avatar_url": "https://avatars3.githubusercontent.com/u/1665010?v=4",
|
||||
"profile": "https://github.com/mmozeiko",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "mazzetta86",
|
||||
"name": "mazzetta86",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/45591560?v=4",
|
||||
"profile": "https://github.com/mazzetta86",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Panzer1119",
|
||||
"name": "Paul Hagedorn",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/23016343?v=4",
|
||||
"profile": "https://github.com/Panzer1119",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
}
|
||||
],
|
||||
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
||||
"contributorsPerLine": 7,
|
||||
"projectName": "overseerr",
|
||||
"projectOwner": "sct",
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"skipCi": true
|
||||
}
|
||||
2
.github/FUNDING.yml
vendored
Normal file
2
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
github: [sct]
|
||||
patreon: overseerr
|
||||
45
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
45
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**What version of Overseerr are you running?**
|
||||
Please fill in the version you are currently running.
|
||||
|
||||
You can find it under: Settings -> About -> Version
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Support via Discord
|
||||
url: https://discord.gg/PkCWJSeCk7
|
||||
about: Chat with users and devs on support and setup related topics.
|
||||
- name: Support via GitHub Discussions
|
||||
url: https://github.com/sct/overseerr/discussions
|
||||
about: Ask questions and discuss with other community members
|
||||
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
13
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
13
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
#### Description
|
||||
|
||||
#### Screenshot (if UI related)
|
||||
|
||||
#### Todos
|
||||
|
||||
- [ ] Sucessfully builds `yarn build`
|
||||
- [ ] Translation Keys `yarn i18n:extract`
|
||||
- [ ] Database migration created (if required)
|
||||
|
||||
#### Issues Fixed or Closed by this PR
|
||||
|
||||
- Fixes #XXXX
|
||||
38
.github/lock.yml
vendored
Normal file
38
.github/lock.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# Configuration for Lock Threads - https://github.com/dessant/lock-threads-app
|
||||
|
||||
# Number of days of inactivity before a closed issue or pull request is locked
|
||||
daysUntilLock: 365
|
||||
|
||||
# Skip issues and pull requests created before a given timestamp. Timestamp must
|
||||
# follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable
|
||||
skipCreatedBefore: false
|
||||
|
||||
# Issues and pull requests with these labels will be ignored. Set to `[]` to disable
|
||||
exemptLabels:
|
||||
- dependencies
|
||||
|
||||
# Label to add before locking, such as `outdated`. Set to `false` to disable
|
||||
lockLabel: false
|
||||
|
||||
# Comment to post before locking. Set to `false` to disable
|
||||
lockComment: >
|
||||
This thread has been automatically locked since there has not been
|
||||
any recent activity after it was closed. Please open a new issue for
|
||||
related bugs.
|
||||
|
||||
# Assign `resolved` as the reason for locking. Set to `false` to disable
|
||||
setLockReason: true
|
||||
# Limit to only `issues` or `pulls`
|
||||
# only: issues
|
||||
|
||||
# Optionally, specify configuration settings just for `issues` or `pulls`
|
||||
# issues:
|
||||
# exemptLabels:
|
||||
# - help-wanted
|
||||
# lockLabel: outdated
|
||||
|
||||
# pulls:
|
||||
# daysUntilLock: 30
|
||||
|
||||
# Repository to extend settings from
|
||||
# _extends: repo
|
||||
18
.github/stale.yml
vendored
Normal file
18
.github/stale.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 60
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- pinned
|
||||
- security
|
||||
- dependencies
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: wontfix
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
35
.github/workflows/ci.yml
vendored
35
.github/workflows/ci.yml
vendored
@@ -26,15 +26,36 @@ jobs:
|
||||
build_and_push:
|
||||
name: Build and push Docker image to Docker Hub
|
||||
needs: test
|
||||
if: github.ref == 'refs/heads/develop'
|
||||
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, 'skip ci')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: Build and push to Docker Hub
|
||||
uses: docker/build-push-action@v1
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
repository: sctx/overseerr
|
||||
tags: develop
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.CR_PAT }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
build-args: |
|
||||
COMMIT_TAG=${{ github.sha }}
|
||||
tags: |
|
||||
sctx/overseerr:develop
|
||||
sctx/overseerr:${{ github.sha }}
|
||||
ghcr.io/sct/overseerr:develop
|
||||
ghcr.io/sct/overseerr:${{ github.sha }}
|
||||
|
||||
19
.github/workflows/invalid_template.yml
vendored
Normal file
19
.github/workflows/invalid_template.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: 'Invalid Template'
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled, unlabeled, reopened]
|
||||
|
||||
jobs:
|
||||
support:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/support-requests@v2
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
support-label: 'invalid:template-incomplete'
|
||||
issue-comment: >
|
||||
:wave: @{issue-author}, please edit your issue and follow the template provided.
|
||||
close-issue: false
|
||||
lock-issue: false
|
||||
issue-lock-reason: 'resolved'
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
run: yarn lint
|
||||
- name: build
|
||||
run: yarn build
|
||||
semnatic-release:
|
||||
semantic-release:
|
||||
name: Tag and release latest version
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
25
.github/workflows/support.yml
vendored
Normal file
25
.github/workflows/support.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: 'Support requests'
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled, unlabeled, reopened]
|
||||
|
||||
jobs:
|
||||
support:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/support-requests@v2
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
support-label: 'support'
|
||||
issue-comment: >
|
||||
:wave: @{issue-author}, we use the issue tracker exclusively
|
||||
for bug reports and feature requests. However, this issue appears
|
||||
to be a support request. Please use our support channels
|
||||
to get help with Overseerr.
|
||||
|
||||
- [Discord](https://discord.gg/PkCWJSeCk7)
|
||||
- [GitHub Discussions](https://github.com/sct/overseerr/discussions)
|
||||
close-issue: true
|
||||
lock-issue: false
|
||||
issue-lock-reason: 'off-topic'
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -6,7 +6,7 @@
|
||||
"typescript",
|
||||
"typescriptreact"
|
||||
],
|
||||
"typescript.tsdk": "./app/node_modules/typescript/lib",
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"sqltools.connections": [
|
||||
{
|
||||
"previewLimit": 50,
|
||||
|
||||
187
CHANGELOG.md
187
CHANGELOG.md
@@ -1,3 +1,190 @@
|
||||
# [1.10.0](https://github.com/sct/overseerr/compare/v1.9.1...v1.10.0) (2020-12-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **email:** fix link to Overseerr in email templates ([816fec1](https://github.com/sct/overseerr/commit/816fec1a83a53edb3b65c3e5e7d0e6e1bd49726d)), closes [#392](https://github.com/sct/overseerr/issues/392)
|
||||
* **frontend:** adjust padding of search box so placeholder text fits on mobile ([3601d44](https://github.com/sct/overseerr/commit/3601d442db32d3f98f7b050365c11ea8ef9bc4ae)), closes [#393](https://github.com/sct/overseerr/issues/393)
|
||||
* **frontend:** changed request block for slideover on mobile UI ([#387](https://github.com/sct/overseerr/issues/387)) ([549567a](https://github.com/sct/overseerr/commit/549567a7e9db01933546d9970fc06f17218dfab1))
|
||||
* **frontend:** hide Request More button if all current seasons are available ([2a4dd52](https://github.com/sct/overseerr/commit/2a4dd52275007e48f946c3b9e29f1d78da57bdaa)), closes [#343](https://github.com/sct/overseerr/issues/343)
|
||||
* **frontend:** try not to render broken rottentomatoes data ([a0c5608](https://github.com/sct/overseerr/commit/a0c5608aa0b6c7a4294300589efa9a662163ce48))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **lang:** translations update from Weblate ([#391](https://github.com/sct/overseerr/issues/391)) ([5f71fb7](https://github.com/sct/overseerr/commit/5f71fb7ee280714275d2ac045c472fcdddd5a2ea))
|
||||
* add missing tzdata package to image ([53bede6](https://github.com/sct/overseerr/commit/53bede692d4f0e940dededa63015fe1908129914)), closes [#394](https://github.com/sct/overseerr/issues/394)
|
||||
* **frontend:** add external links to movie and tv detail pages ([a0024a0](https://github.com/sct/overseerr/commit/a0024a0cbe717d78f53413bb78644c829f143c4d))
|
||||
* **lang:** translations update from Weblate ([#380](https://github.com/sct/overseerr/issues/380)) ([8408e19](https://github.com/sct/overseerr/commit/8408e19568b2f239c57e11e2946c75f193d1c22e))
|
||||
|
||||
## [1.9.1](https://github.com/sct/overseerr/compare/v1.9.0...v1.9.1) (2020-12-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* change default internal port to 5055 ([#389](https://github.com/sct/overseerr/issues/389)) ([5e5ba40](https://github.com/sct/overseerr/commit/5e5ba4050563f07bff367d2fb31ed7e7fca4291e))
|
||||
|
||||
# [1.9.0](https://github.com/sct/overseerr/compare/v1.8.0...v1.9.0) (2020-12-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* api key regeneration ([6beac73](https://github.com/sct/overseerr/commit/6beac736efcf7b9102e02e43b75d91a9a158cd22))
|
||||
* **api:** add movie keyword search ([f88c4a6](https://github.com/sct/overseerr/commit/f88c4a6d4a49f8f3451ba6c85153677f33b7f5f6))
|
||||
* **frontend:** add studio/networks to movie/tv details ([4b6ad8a](https://github.com/sct/overseerr/commit/4b6ad8a3871957db4192b603abf38404250cea5d)), closes [#370](https://github.com/sct/overseerr/issues/370)
|
||||
* **frontend:** added user deletion to the user list ([727fa06](https://github.com/sct/overseerr/commit/727fa06c18febb2a97ca219cc6bf0277ff462acd)), closes [#348](https://github.com/sct/overseerr/issues/348)
|
||||
* **holiday:** special seasonal slider added to discover :) ([908f635](https://github.com/sct/overseerr/commit/908f63557ca03a1da8b16809ffa2c3acd782d94e))
|
||||
* allow to listen server on specific host interface ([#381](https://github.com/sct/overseerr/issues/381)) ([086183b](https://github.com/sct/overseerr/commit/086183b5636aa8d075d01fe59492c3eab0d1345b)), closes [#273](https://github.com/sct/overseerr/issues/273)
|
||||
* anime profile support ([#384](https://github.com/sct/overseerr/issues/384)) ([0972f40](https://github.com/sct/overseerr/commit/0972f40a4e1fb3b5f02b07ae46b997d71aab9bfb)), closes [#266](https://github.com/sct/overseerr/issues/266)
|
||||
|
||||
# [1.8.0](https://github.com/sct/overseerr/compare/v1.7.0...v1.8.0) (2020-12-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **lang:** translations update from Weblate ([#336](https://github.com/sct/overseerr/issues/336)) ([ee84f74](https://github.com/sct/overseerr/commit/ee84f74f8a3558875b41daa539f42d00b949898a))
|
||||
|
||||
# [1.7.0](https://github.com/sct/overseerr/compare/v1.6.0...v1.7.0) (2020-12-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **email:** do not pass auth object to transport if no auth data present ([d5eb4d8](https://github.com/sct/overseerr/commit/d5eb4d8d438a159266b2de66b6bcdd9440a0c8ef)), closes [#312](https://github.com/sct/overseerr/issues/312)
|
||||
* **frontend:** add http/https prefix to hostname fields for plex/radarr/sonarr ([ce0266f](https://github.com/sct/overseerr/commit/ce0266f74ea3979b291ff962271a928682892788)), closes [#357](https://github.com/sct/overseerr/issues/357)
|
||||
* **frontend:** clarify that radarr/sonnarr servers must be tested before profiles/folders appear ([fc12ab8](https://github.com/sct/overseerr/commit/fc12ab84d9482eb3a11f117f8cab6fd48a9401cd)), closes [#326](https://github.com/sct/overseerr/issues/326) [#328](https://github.com/sct/overseerr/issues/328)
|
||||
* **frontend:** correctly show an unauthorized error when a user fails to login ([18925de](https://github.com/sct/overseerr/commit/18925decafdac518f52a354c594cc378d2529022)), closes [#322](https://github.com/sct/overseerr/issues/322)
|
||||
* **frontend:** fix tv shows failing to open when firstAirDate is undefined ([c21fa5b](https://github.com/sct/overseerr/commit/c21fa5b5350abdd8e03c077fde7246fa398e176e)), closes [#347](https://github.com/sct/overseerr/issues/347)
|
||||
* **frontend:** make minimum availability required for Radarr servers ([2fe53ec](https://github.com/sct/overseerr/commit/2fe53ec5a8534e75c7d0cef31a8b46065111e0a7)), closes [#345](https://github.com/sct/overseerr/issues/345)
|
||||
* **plex-sync:** bundle duplicate ratingKeys to speed up recently added sync ([67146c3](https://github.com/sct/overseerr/commit/67146c33ef7f28d520ba2c50b32673d43f4525c8)), closes [#360](https://github.com/sct/overseerr/issues/360)
|
||||
* **sonarr.ts, mediarequest.ts:** add missing seasonFolder option ([#358](https://github.com/sct/overseerr/issues/358)) ([e9c899c](https://github.com/sct/overseerr/commit/e9c899ce419d149dde2ad9a0f7d5a2f2545b3ebf))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** show alert when there are no default radarr/sonarr servers ([0d088e0](https://github.com/sct/overseerr/commit/0d088e085e68d39455fda21d1fd08ebcaef2c06b)), closes [#344](https://github.com/sct/overseerr/issues/344)
|
||||
|
||||
# [1.6.0](https://github.com/sct/overseerr/compare/v1.5.0...v1.6.0) (2020-12-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** accept the api key to perform actions on the api with X-API-Key header ([33f8831](https://github.com/sct/overseerr/commit/33f8831e880dc7fd3f69d951246cada5c6c0ffe7))
|
||||
* **api:** filter out libraries that do not have any metadata agent or are not movie/show ([01c179f](https://github.com/sct/overseerr/commit/01c179f762e686a1e5a3d4dab3a5bea53425b575))
|
||||
* **api:** only run recently added sync on enabled libraries ([e08fa35](https://github.com/sct/overseerr/commit/e08fa35548bb8644afa8df3124e6f9cc3a2c8f4a)), closes [#259](https://github.com/sct/overseerr/issues/259)
|
||||
* **api:** set plex libraries to disabled if the name changes ([675060b](https://github.com/sct/overseerr/commit/675060bcdf23acbfd4de2900a65f95e74f4966a5)), closes [#324](https://github.com/sct/overseerr/issues/324)
|
||||
* **frontend:** adds a tip to plex setup to clarify that syncing runs in the background ([df4ac83](https://github.com/sct/overseerr/commit/df4ac8361f82971ee845f3be217408a9123a0bf3)), closes [#325](https://github.com/sct/overseerr/issues/325)
|
||||
* **frontend:** aligned movie and tv details ([#331](https://github.com/sct/overseerr/issues/331)) ([db0a5c4](https://github.com/sct/overseerr/commit/db0a5c44f678e76eee7f5582381016306d1f46a2))
|
||||
* **frontend:** close sidebar when clicking outside ([#333](https://github.com/sct/overseerr/issues/333)) ([6d7907e](https://github.com/sct/overseerr/commit/6d7907e844a909993d185759d660632f55aeaa35))
|
||||
* spelling mistake on the word 'requested' fixed ([#319](https://github.com/sct/overseerr/issues/319)) ([961d110](https://github.com/sct/overseerr/commit/961d1107208069a6fc820a1ba97ffda7336677cb))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add version to startup logs ([2948f93](https://github.com/sct/overseerr/commit/2948f9360eb484d1d6c0740a840135ca97e7240a))
|
||||
* **frontend:** temporary logs page to clear up confusion about it 404ing ([d9788c4](https://github.com/sct/overseerr/commit/d9788c4aa9f87e2eda3f7e3f1adc985f16039552)), closes [#272](https://github.com/sct/overseerr/issues/272)
|
||||
* **lang:** add support for Spanish language ([6cd2049](https://github.com/sct/overseerr/commit/6cd20491d2a0ceb995c4744eeb92a6e2f57a4893))
|
||||
* **lang:** Translations update from Weblate ([#291](https://github.com/sct/overseerr/issues/291)) ([fddbb3c](https://github.com/sct/overseerr/commit/fddbb3cdfe3d50b2835c248556139c769dc2b805))
|
||||
|
||||
# [1.5.0](https://github.com/sct/overseerr/compare/v1.4.0...v1.5.0) (2020-12-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** require package.json directly so typescript doesnt compile it into dist folder ([b9faa64](https://github.com/sct/overseerr/commit/b9faa6486b35aa865019aa8af9d307531054bc1d))
|
||||
* **frontend:** add validation for Radarr/Sonarr server name ([b5988f9](https://github.com/sct/overseerr/commit/b5988f9a5ff274e97f208c2726abe76c22c858ee))
|
||||
* **frontend:** only show alpha notice to admins ([ff61895](https://github.com/sct/overseerr/commit/ff618956b5d9cf933d867ea979b612c3d8a6f30b))
|
||||
* add support for ssl when connecting to plex ([3ba09d0](https://github.com/sct/overseerr/commit/3ba09d07eb0367c41603cd55e7ff41c66fb641c4)), closes [#275](https://github.com/sct/overseerr/issues/275)
|
||||
* **services:** improve logging for when Radarr movie already exists ([#285](https://github.com/sct/overseerr/issues/285)) ([f998873](https://github.com/sct/overseerr/commit/f998873fc5669a547901f2733c9c785d744d27ca)), closes [#260](https://github.com/sct/overseerr/issues/260)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **lang:** add i18n strings for new about page ([900827b](https://github.com/sct/overseerr/commit/900827be97845688e4bea72a8c5d9611a3e9d069))
|
||||
* about page initial version ([3f2a04c](https://github.com/sct/overseerr/commit/3f2a04c881bf06b73a952181fa463af84454b0dd))
|
||||
|
||||
# [1.4.0](https://github.com/sct/overseerr/compare/v1.3.2...v1.4.0) (2020-12-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* changing parameter name to use correct 'port' [#276](https://github.com/sct/overseerr/issues/276) ([#277](https://github.com/sct/overseerr/issues/277)) ([6d08b10](https://github.com/sct/overseerr/commit/6d08b108200177ca3068c852e60a0df75ce2232a))
|
||||
* **services:** include radarr/sonarr baseUrl when adding media ([78af1a3](https://github.com/sct/overseerr/commit/78af1a3e6d00a5645a05e7bf3cf56a59439b6cc9))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **lang:** Translations update from Weblate ([#240](https://github.com/sct/overseerr/issues/240)) ([e17c637](https://github.com/sct/overseerr/commit/e17c63748362b6a480693e003ef5eec614dcec43))
|
||||
|
||||
## [1.3.2](https://github.com/sct/overseerr/compare/v1.3.1...v1.3.2) (2020-12-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** convert plex port to a number before posting to the api ([8cb05c4](https://github.com/sct/overseerr/commit/8cb05c413a15a4b74e37ece5e24367d115995b32))
|
||||
* **frontend:** converts email smtp port to a number before posting to the api ([2098a2d](https://github.com/sct/overseerr/commit/2098a2d3d2981fd2ae54392aec3ef81327f2858e)), closes [#251](https://github.com/sct/overseerr/issues/251)
|
||||
* **frontend:** encode special characters in search input to prevent crashing router ([15013d6](https://github.com/sct/overseerr/commit/15013d6c5dbff15704c7c30d261d68a265e7f2d7)), closes [#252](https://github.com/sct/overseerr/issues/252)
|
||||
* **plex sync:** catch errors that occur during processMovie ([edbbccf](https://github.com/sct/overseerr/commit/edbbccf3ae623430294f1a5c3fd2728dbd42e555)), closes [#244](https://github.com/sct/overseerr/issues/244) [#246](https://github.com/sct/overseerr/issues/246) [#250](https://github.com/sct/overseerr/issues/250)
|
||||
* **services:** improve logging for adding movies to Radarr ([6c1ee83](https://github.com/sct/overseerr/commit/6c1ee830a183f89bb1fe96a181a7d61684e23b22))
|
||||
* **services:** radarr/sonarr will use the correct default server ([0658b79](https://github.com/sct/overseerr/commit/0658b7943e1ab25816db9da34d4c9ea808d9203d))
|
||||
|
||||
## [1.3.1](https://github.com/sct/overseerr/compare/v1.3.0...v1.3.1) (2020-12-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** also convert activeProfileId to a number for radarr/sonarr submissions ([7bf924f](https://github.com/sct/overseerr/commit/7bf924f7e94a0e0834f41b4ec067ed277c652766))
|
||||
* **frontend:** also convert ports to numbers when saving radarr/sonarr servers ([c53dc3b](https://github.com/sct/overseerr/commit/c53dc3b15da522c6e6ab76bbc9d15008a8a9fb9d))
|
||||
* **frontend:** new radarr/sonarr ports will be converted to a number before posting ([92c9001](https://github.com/sct/overseerr/commit/92c9001c9d1f2cbd272a5897ea1157d2cadbce2d))
|
||||
|
||||
# [1.3.0](https://github.com/sct/overseerr/compare/v1.2.0...v1.3.0) (2020-12-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** correctly generate clientId on first startup ([5f09e83](https://github.com/sct/overseerr/commit/5f09e83ed870336638d3e9d94fcf55ead928e737))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** add full cast page for movies and series ([051f1b3](https://github.com/sct/overseerr/commit/051f1b3e899bf749e632743e5c8d45a02b621998))
|
||||
* **lang:** translated using Weblate (Dutch) ([1ab3a4b](https://github.com/sct/overseerr/commit/1ab3a4b80a081d7e4a201f1290cd270ed5b38ac7))
|
||||
* **lang:** translated using Weblate (English) ([0949c9b](https://github.com/sct/overseerr/commit/0949c9b334b3a4b6c342517a157a9e2b7596f2f0))
|
||||
* **lang:** translated using Weblate (French) ([f943701](https://github.com/sct/overseerr/commit/f943701e13c7f0de5a711302597858cc898b16e2))
|
||||
* **lang:** translated using Weblate (French) ([30d04ce](https://github.com/sct/overseerr/commit/30d04ce35adc21070cce37ab10384154afda191b))
|
||||
* **lang:** translated using Weblate (German) ([7bf9add](https://github.com/sct/overseerr/commit/7bf9addd13a707aac23b64ef3f1733e491d40a4e))
|
||||
* **lang:** translated using Weblate (German) ([b6e60a4](https://github.com/sct/overseerr/commit/b6e60a412b30907aea751a4cf1ce0cc8230f9814))
|
||||
* **lang:** translated using Weblate (Japanese) ([08e968f](https://github.com/sct/overseerr/commit/08e968fd0097ec7b2a65de064ed5b07e7c49ef39))
|
||||
* **lang:** translated using Weblate (Norwegian Bokmål) ([83efb0e](https://github.com/sct/overseerr/commit/83efb0e3d4d96b6a2d2ebdd85d36c9d78c1717b2))
|
||||
* **lang:** translated using Weblate (Russian) ([0d8e0d0](https://github.com/sct/overseerr/commit/0d8e0d0352f72fdb65ee8f054371eae08c39fe33))
|
||||
|
||||
# [1.2.0](https://github.com/sct/overseerr/compare/v1.1.0...v1.2.0) (2020-12-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** person cards now show correctly in ListView's ([ccb9855](https://github.com/sct/overseerr/commit/ccb98553f104c1aebd33796b7090cc9bbe964bd7))
|
||||
* **frontend:** properly remove site overlay when closing modals ([3fa7ff9](https://github.com/sct/overseerr/commit/3fa7ff9858d14d132151f3329164d55d74638f53))
|
||||
* **frontend:** switch to using Transition component for modals ([b16fbaf](https://github.com/sct/overseerr/commit/b16fbafa1f3d5e105c0a4ba6f1d66aa064019636)), closes [#220](https://github.com/sct/overseerr/issues/220)
|
||||
* fix missing personid in Discover ([d8060af](https://github.com/sct/overseerr/commit/d8060afe02574337f51b88cab0a0f824976ac721))
|
||||
* missing personId in ListView component ([6502feb](https://github.com/sct/overseerr/commit/6502feb1a5be3c6daab33230814fe74632c87f7e))
|
||||
* **frontend:** update overflow issues with seasons + email ([#217](https://github.com/sct/overseerr/issues/217)) ([2d0afb2](https://github.com/sct/overseerr/commit/2d0afb29d37798a626e3f182571ccce43d80063c)), closes [#216](https://github.com/sct/overseerr/issues/216)
|
||||
* **lang:** fix missing i18n string for agent enabled in email notification page ([42788ad](https://github.com/sct/overseerr/commit/42788adb75f7d23e68327688b1c542dd047e9609))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **lang:** update language files ([8cd067b](https://github.com/sct/overseerr/commit/8cd067b6e9df1a3c8f4056789436a31177703986))
|
||||
* person details page ([d6eb3ae](https://github.com/sct/overseerr/commit/d6eb3ae64ef46bd62145010d3029e272676487c3))
|
||||
* **lang:** add nb-NO and de language support to app ([d38b28d](https://github.com/sct/overseerr/commit/d38b28d2061b38366989ff412957a5dee5766c6f))
|
||||
* **lang:** add support for dutch language ([df94db0](https://github.com/sct/overseerr/commit/df94db050bf68a925118e0ce865d27178b702f9e))
|
||||
* **lang:** add support for russian languge ([8d8e750](https://github.com/sct/overseerr/commit/8d8e7509826514eebc859374d2e1ab212cc442d1))
|
||||
* **lang:** added translation using Weblate (Russian) ([887f5dd](https://github.com/sct/overseerr/commit/887f5dd487b61676029652d99cbc5b40213aa22e))
|
||||
* **lang:** translated using Weblate (French) ([30a8934](https://github.com/sct/overseerr/commit/30a8934626fa2d47e95b5925d7e4227a0d0aa728))
|
||||
* **lang:** translated using Weblate (German) ([44dbb74](https://github.com/sct/overseerr/commit/44dbb745b6216ce19fab4740520785c6414cf367))
|
||||
* **lang:** translated using Weblate (Japanese) ([a494507](https://github.com/sct/overseerr/commit/a494507dfeafb0cfd2bd66fb01138522e0e80737))
|
||||
* **lang:** translated using Weblate (Russian) ([86cadb8](https://github.com/sct/overseerr/commit/86cadb8283fcab8745b4c09f8429fd9e46708813))
|
||||
* **lang:** translations update from Weblate ([#201](https://github.com/sct/overseerr/issues/201)) ([b0c663b](https://github.com/sct/overseerr/commit/b0c663baccd994e234b4d41d86486c3af4906344))
|
||||
|
||||
# [1.1.0](https://github.com/sct/overseerr/compare/v1.0.0...v1.1.0) (2020-12-08)
|
||||
|
||||
|
||||
|
||||
132
CODE_OF_CONDUCT.md
Normal file
132
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
[ryan@sct.dev](mailto:ryan@sct.dev).
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][mozilla coc].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][faq]. Translations are available
|
||||
at [https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
|
||||
[mozilla coc]: https://github.com/mozilla/diversity
|
||||
[faq]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
49
CONTRIBUTING.md
Normal file
49
CONTRIBUTING.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Contributing to Overseerr
|
||||
|
||||
All help is welcome and greatly appreciated. If you would like to contribute to the project the steps below can get you started:
|
||||
|
||||
## Development
|
||||
|
||||
### Tools Required
|
||||
|
||||
- HTML/Typescript/Javascript editor of choice. ([VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install.)
|
||||
- [NodeJS](https://nodejs.org/en/download/) (Node 12.x.x or higher)
|
||||
- [Yarn](https://yarnpkg.com/)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
||||
2. Create a new branch `git checkout -b BRANCH_NAME`
|
||||
|
||||
- Its recommended to name the branch something relevant to the feature or fix you are working on.
|
||||
- An example of this would be `fix-title-cards` or `feature-new-system`.
|
||||
- Bad examples would be `patch` or `bug`.
|
||||
|
||||
3. Install dependencies `yarn`
|
||||
4. `yarn dev` to build and watch for changes
|
||||
|
||||
You can also run the development environment in [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
|
||||
|
||||
### Contributing Code
|
||||
|
||||
- If you are taking on an existing bug or feature ticket, please comment on the [GitHub Issue](https://github.com/sct/overseerr/issues) to avoid multiple people working on the same thing.
|
||||
- All commits **must** follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
- It is okay if you squash your PR down to be a single commit that fits this standard.
|
||||
- PRs with commits not following this standard will not be merged.
|
||||
- Please make meaningful commits, or squash them
|
||||
- Always rebase your commit to the latest `develop` branch. Do not merge develop into your branch.
|
||||
- It is your responsibility to keep your branch up to date. It will not be merged unless its rebased off the latest develop branch.
|
||||
- You can create a Draft pull request early to get feedback on your work.
|
||||
- Your code must be formatted correctly or the tests will fail.
|
||||
- We use Prettier to format our codebase. It should auto run with a git hook, but its recommended to have a Prettier extension installed in your editor and have it format on save.
|
||||
- If you have questions or need help, you can reach out in [GitHub Discussions](https://github.com/sct/overseerr/discussions) or in our [Discord](https://discord.gg/PkCWJSeCk7).
|
||||
- Only open pull requests to `develop`. Never `master`. Any PR's opened to master will be closed.
|
||||
|
||||
## Translation
|
||||
|
||||
We use [Weblate](https://hosted.weblate.org/engage/overseerr/) for our translations so please feel free to contribute to localizing Overseerr!
|
||||
|
||||
## Attribution
|
||||
|
||||
This contribution guide was inspired by the [Next.js](https://github.com/vercel/next.js) and [Radarr](https://github.com/Radarr/Radarr) contribution guides.
|
||||
@@ -12,6 +12,11 @@ RUN yarn cache clean
|
||||
|
||||
FROM node:12.18-alpine
|
||||
|
||||
ARG COMMIT_TAG
|
||||
ENV COMMIT_TAG=${COMMIT_TAG}
|
||||
|
||||
RUN apk add tzdata
|
||||
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
@@ -21,3 +26,5 @@ COPY --from=BUILD_IMAGE /app/.next ./.next
|
||||
COPY --from=BUILD_IMAGE /app/node_modules ./node_modules
|
||||
|
||||
CMD yarn start
|
||||
|
||||
EXPOSE 5055
|
||||
|
||||
99
README.md
99
README.md
@@ -6,7 +6,7 @@
|
||||
<img src="https://github.com/sct/overseerr/workflows/Overseerr%20CI/badge.svg" alt="Overseerr CI">
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/ySfaEUcQ">
|
||||
<a href="https://discord.gg/PkCWJSeCk7">
|
||||
<img src="https://img.shields.io/discord/783137440809746482" alt="Discord">
|
||||
</a>
|
||||
<img src="https://img.shields.io/docker/pulls/sctx/overseerr" alt="Docker pulls">
|
||||
@@ -14,48 +14,53 @@
|
||||
<img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/svg-badge.svg" alt="Translation status" />
|
||||
</a>
|
||||
<a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a>
|
||||
<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-18-orange.svg"/></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
</p>
|
||||
|
||||
**Overseerr** is a tool for managing requests for your media library. It integrates with existing services such as **Sonarr** and **Radarr**!
|
||||
**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**!
|
||||
|
||||
## Current Features
|
||||
|
||||
- Full Plex integration. Login and manage user access with Plex!
|
||||
- Integrates easily with your existing services. Currently Overseerr supports Sonarr and Radarr. More in the future!
|
||||
- Integrates easily with your existing services. Currently Overseerr supports Sonarr and Radarr. More to come!
|
||||
- Syncs to your Plex library to know what titles you already have.
|
||||
- Complex request system that allows users to request individual seasons or movies in a friendly, easy to use UI.
|
||||
- Complex request system allowing users to request individual seasons or movies in a friendly, easy to use UI.
|
||||
- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests.
|
||||
- Granular permissions system
|
||||
- Granular permission system
|
||||
- Mobile friendly design, for when you need to approve requests on the go!
|
||||
|
||||
## In Development
|
||||
|
||||
- User profiles
|
||||
- User settings page to give users the ability to modify their Overseerr experience to their liking
|
||||
- Version update notifications in-app
|
||||
- User profiles.
|
||||
- User settings page (to give users the ability to modify their Overseerr experience to their liking).
|
||||
- Version update notifications in-app.
|
||||
- 4K requests (Includes multi-radarr/sonarr management for media)
|
||||
|
||||
## Planned Features
|
||||
|
||||
- More notification types (Slack/Telegram/etc)
|
||||
- More notification types (Slack/Telegram/etc.).
|
||||
- Issues system. This will allow users to report issues with content on your media server.
|
||||
- Local user system (for those who do not use Plex)
|
||||
- Compatiblity APIs to work with existing tools in your system
|
||||
- Local user system (for those who don't use Plex).
|
||||
- Compatibility APIs (to work with existing tools in your system).
|
||||
|
||||
## Running Overseerr
|
||||
|
||||
Currently, the only distribution of Overseerr is through Docker images. If you have Docker, you can run Overseerr with the following command:
|
||||
Currently, Overseerr is only distributed through Docker images. If you have Docker, you can run Overseerr as per:
|
||||
|
||||
```
|
||||
docker run -d \
|
||||
-e LOG_LEVEL=info \
|
||||
-e TZ=Asia/Tokyo \
|
||||
-p 5055:3000 \
|
||||
-v /path/to/appdata/config:/config \
|
||||
-p 5055:5055 \
|
||||
-v /path/to/appdata/config:/app/config \
|
||||
--restart unless-stopped \
|
||||
sctx/overseer
|
||||
sctx/overseerr
|
||||
```
|
||||
|
||||
After running Overseerr for the first time, visit the web UI at http://[address]:5055 and complete the setup steps to configure Overseerr.
|
||||
After running Overseerr for the first time, configure it by visiting the web UI at http://[address]:5055 and completing the setup steps.
|
||||
|
||||
⚠️ Overseerr is currently under very heavy, rapid development and things are likely to break often. We need all the help we can get to find bugs and get them fixed to hit a more stable release. If you would like to help test the bleeding edge, please use the image **sctx/overseerr:develop** instead! ⚠️
|
||||
|
||||
@@ -65,34 +70,60 @@ After running Overseerr for the first time, visit the web UI at http://[address]
|
||||
|
||||
## Support
|
||||
|
||||
- You can reach us for support on [Discord](https://discord.gg/ySfaEUcQ).
|
||||
- Bugs can be opened with an issue on [Github](https://github.com/sct/overseerr/issues).
|
||||
- You can get support on [Discord](https://discord.gg/PkCWJSeCk7).
|
||||
- You can ask questions in the Help category of our [GitHub Discussions](https://github.com/sct/overseerr/discussions).
|
||||
- Bugs/Feature Requests can be opened via a [GitHub issue](https://github.com/sct/overseerr/issues).
|
||||
|
||||
## API Documentation
|
||||
|
||||
Full API documentation will soon be published automatically and available outside of running the app. But currently, you can access the api docs by running Overseerr locally and visiting http://localhost:3000/api-docs
|
||||
Full API documentation will soon be published automatically and available outside of running the app. Currently, you can access the API docs by running Overseerr locally and visiting http://localhost:5055/api-docs
|
||||
|
||||
## Contribution
|
||||
## Community
|
||||
|
||||
Anyone is welcome to contribute to Docker and pull requests are greatly appreciated! Contributors will be recognized in the future on this very README.
|
||||
You can ask questions, share ideas, and more in [GitHub Discussions](https://github.com/sct/overseerr/discussions).
|
||||
|
||||
### Developing Overseerr
|
||||
If you would like to chat with community members you can join the [Overseerr Discord](https://discord.gg/PkCWJSeCk7).
|
||||
|
||||
You can develop Overseer entirely in docker. Make sure you have [Docker Desktop](https://www.docker.com/products/docker-desktop) installed before continuing.
|
||||
Our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md) applies to all Overseerr community channels.
|
||||
|
||||
1. Make sure you have [Docker Desktop](https://www.docker.com/products/docker-desktop) installed.
|
||||
2. Run `docker-compose up -d` to start the server.
|
||||
3. Access the container at http://localhost:3000
|
||||
## Contributing
|
||||
|
||||
If Docker isn't your jam, you can always run Overseer with the following yarn commands:
|
||||
You can help build Overseerr too! Check out our [Contribution Guide](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md) to get started.
|
||||
|
||||
```
|
||||
yarn
|
||||
yarn dev
|
||||
```
|
||||
## Contributors ✨
|
||||
|
||||
You will need NodeJS installed. Once it's built and running, access it locally at http://localhost:3000 just like Docker.
|
||||
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
|
||||
|
||||
### Translation
|
||||
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||
<!-- prettier-ignore-start -->
|
||||
<!-- 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>
|
||||
</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>
|
||||
</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>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
We use [Weblate](https://hosted.weblate.org/engage/overseerr/) for our translations so please feel free to contribute to localizing Overseerr!
|
||||
<!-- markdownlint-enable -->
|
||||
<!-- prettier-ignore-end -->
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
@@ -5,7 +5,7 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile.local
|
||||
ports:
|
||||
- 3000:3000
|
||||
- 5055:5055
|
||||
volumes:
|
||||
- .:/app:rw,cached
|
||||
- /app/node_modules
|
||||
|
||||
@@ -5,6 +5,7 @@ const devConfig = {
|
||||
logging: false,
|
||||
entities: ['server/entity/**/*.ts'],
|
||||
migrations: ['server/migration/**/*.ts'],
|
||||
subscribers: ['server/subscriber/**/*.ts'],
|
||||
cli: {
|
||||
entitiesDir: 'server/entity',
|
||||
migrationsDir: 'server/migration',
|
||||
@@ -19,6 +20,7 @@ const prodConfig = {
|
||||
entities: ['dist/entity/**/*.js'],
|
||||
migrations: ['dist/migration/**/*.js'],
|
||||
migrationsRun: true,
|
||||
subscribers: ['dist/subscriber/**/*.js'],
|
||||
cli: {
|
||||
entitiesDir: 'dist/entity',
|
||||
migrationsDir: 'dist/migration',
|
||||
|
||||
@@ -375,6 +375,7 @@ components:
|
||||
example: 1
|
||||
logoPath:
|
||||
type: string
|
||||
nullable: true
|
||||
originCountry:
|
||||
type: string
|
||||
name:
|
||||
@@ -428,18 +429,14 @@ components:
|
||||
releaseDate:
|
||||
type: string
|
||||
revenue:
|
||||
type: string
|
||||
type: number
|
||||
nullable: true
|
||||
runtime:
|
||||
type: number
|
||||
spokenLanguages:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
iso_639_1:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
$ref: '#/components/schemas/SpokenLanguage'
|
||||
status:
|
||||
type: string
|
||||
tagline:
|
||||
@@ -476,6 +473,7 @@ components:
|
||||
type: string
|
||||
airDate:
|
||||
type: string
|
||||
nullable: true
|
||||
episodeNumber:
|
||||
type: number
|
||||
overview:
|
||||
@@ -483,11 +481,12 @@ components:
|
||||
productionCode:
|
||||
type: string
|
||||
seasonNumber:
|
||||
type: string
|
||||
type: number
|
||||
showId:
|
||||
type: number
|
||||
stillPath:
|
||||
type: string
|
||||
nullable: true
|
||||
voteAverage:
|
||||
type: number
|
||||
voteCount:
|
||||
@@ -499,6 +498,7 @@ components:
|
||||
type: number
|
||||
airDate:
|
||||
type: string
|
||||
nullable: true
|
||||
episodeCount:
|
||||
type: number
|
||||
name:
|
||||
@@ -536,6 +536,7 @@ components:
|
||||
type: number
|
||||
profilePath:
|
||||
type: string
|
||||
nullable: true
|
||||
episodeRunTime:
|
||||
type: array
|
||||
items:
|
||||
@@ -586,6 +587,10 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ProductionCompany'
|
||||
spokenLanguages:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SpokenLanguage'
|
||||
seasons:
|
||||
type: array
|
||||
items:
|
||||
@@ -611,6 +616,10 @@ components:
|
||||
$ref: '#/components/schemas/Crew'
|
||||
externalIds:
|
||||
$ref: '#/components/schemas/ExternalIds'
|
||||
keywords:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Keyword'
|
||||
mediaInfo:
|
||||
$ref: '#/components/schemas/MediaInfo'
|
||||
MediaRequest:
|
||||
@@ -637,7 +646,6 @@ components:
|
||||
readOnly: true
|
||||
requestedBy:
|
||||
$ref: '#/components/schemas/User'
|
||||
readOnly: true
|
||||
modifiedBy:
|
||||
anyOf:
|
||||
- $ref: '#/components/schemas/User'
|
||||
@@ -658,6 +666,7 @@ components:
|
||||
tvdbId:
|
||||
type: number
|
||||
readOnly: true
|
||||
nullable: true
|
||||
status:
|
||||
type: number
|
||||
requests:
|
||||
@@ -696,6 +705,7 @@ components:
|
||||
type: number
|
||||
profilePath:
|
||||
type: string
|
||||
nullable: true
|
||||
Crew:
|
||||
type: object
|
||||
properties:
|
||||
@@ -715,25 +725,34 @@ components:
|
||||
type: string
|
||||
profilePath:
|
||||
type: string
|
||||
nullable: true
|
||||
ExternalIds:
|
||||
type: object
|
||||
properties:
|
||||
facebookId:
|
||||
type: string
|
||||
nullable: true
|
||||
freebaseId:
|
||||
type: string
|
||||
nullable: true
|
||||
freebaseMid:
|
||||
type: string
|
||||
nullable: true
|
||||
imdbId:
|
||||
type: string
|
||||
nullable: true
|
||||
instagramId:
|
||||
type: string
|
||||
nullable: true
|
||||
tvdbId:
|
||||
type: string
|
||||
type: number
|
||||
nullable: true
|
||||
tvrageId:
|
||||
type: string
|
||||
type: number
|
||||
nullable: true
|
||||
twitterId:
|
||||
type: string
|
||||
nullable: true
|
||||
ServiceProfile:
|
||||
type: object
|
||||
properties:
|
||||
@@ -887,6 +906,8 @@ components:
|
||||
type: string
|
||||
character:
|
||||
type: string
|
||||
mediaInfo:
|
||||
$ref: '#/components/schemas/MediaInfo'
|
||||
CreditCrew:
|
||||
type: object
|
||||
properties:
|
||||
@@ -941,17 +962,45 @@ components:
|
||||
type: string
|
||||
job:
|
||||
type: string
|
||||
mediaInfo:
|
||||
$ref: '#/components/schemas/MediaInfo'
|
||||
Keyword:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
example: 1
|
||||
name:
|
||||
type: string
|
||||
example: 'anime'
|
||||
SpokenLanguage:
|
||||
type: object
|
||||
properties:
|
||||
englishName:
|
||||
type: string
|
||||
example: 'English'
|
||||
nullable: true
|
||||
iso_639_1:
|
||||
type: string
|
||||
example: 'en'
|
||||
name:
|
||||
type: string
|
||||
example: 'English'
|
||||
securitySchemes:
|
||||
cookieAuth:
|
||||
type: apiKey
|
||||
name: connect.sid
|
||||
in: cookie
|
||||
apiKey:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-Api-Key
|
||||
|
||||
paths:
|
||||
/settings/main:
|
||||
get:
|
||||
summary: Returns main settings
|
||||
description: Retreives all main settings in JSON format
|
||||
description: Retrieves all main settings in JSON format
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
@@ -979,6 +1028,19 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MainSettings'
|
||||
/settings/main/regenerate:
|
||||
get:
|
||||
summary: Returns main settings with newly generated API Key
|
||||
description: Retreives all main settings in JSON format with new API Key
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MainSettings'
|
||||
/settings/plex:
|
||||
get:
|
||||
summary: Returns plex settings
|
||||
@@ -1116,7 +1178,7 @@ paths:
|
||||
/settings/radarr/test:
|
||||
post:
|
||||
summary: Test radarr configuration
|
||||
description: Test if the provided Radarr congifuration values are valid. Returns profiles and root folders on success
|
||||
description: Test if the provided Radarr configuration values are valid. Returns profiles and root folders on success
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
@@ -1260,7 +1322,7 @@ paths:
|
||||
/settings/sonarr/test:
|
||||
post:
|
||||
summary: Test Sonarr configuration
|
||||
description: Test if the provided Sonarr congifuration values are valid. Returns profiles and root folders on success
|
||||
description: Test if the provided Sonarr configuration values are valid. Returns profiles and root folders on success
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
@@ -1363,7 +1425,7 @@ paths:
|
||||
/settings/initialize:
|
||||
get:
|
||||
summary: Set the application as initialized
|
||||
description: Sets the app as initalized and allows the user to navigate to pages other than the setup page
|
||||
description: Sets the app as initialized and allows the user to navigate to pages other than the setup page
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
@@ -1457,6 +1519,33 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DiscordSettings'
|
||||
/settings/about:
|
||||
get:
|
||||
summary: Return current about stats
|
||||
description: Returns current server stats in JSON format
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
'200':
|
||||
description: Returned about settings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
example: '1.0.0'
|
||||
totalRequests:
|
||||
type: number
|
||||
example: 100
|
||||
totalMediaItems:
|
||||
type: number
|
||||
example: 100
|
||||
tz:
|
||||
type: string
|
||||
nullable: true
|
||||
example: Asia/Tokyo
|
||||
/auth/me:
|
||||
get:
|
||||
summary: Returns the currently logged in user
|
||||
@@ -1823,6 +1912,51 @@ paths:
|
||||
- $ref: '#/components/schemas/MovieResult'
|
||||
- $ref: '#/components/schemas/TvResult'
|
||||
- $ref: '#/components/schemas/PersonResult'
|
||||
/discover/keyword/{keywordId}/movies:
|
||||
get:
|
||||
summary: Request list of movies from keyword
|
||||
description: Returns list of movies based on provided keyword ID in JSON format
|
||||
tags:
|
||||
- search
|
||||
parameters:
|
||||
- in: path
|
||||
name: keywordId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
example: 207317
|
||||
- in: query
|
||||
name: page
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
default: 1
|
||||
- in: query
|
||||
name: language
|
||||
schema:
|
||||
type: string
|
||||
example: en
|
||||
responses:
|
||||
'200':
|
||||
description: List of movies
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
page:
|
||||
type: number
|
||||
example: 1
|
||||
totalPages:
|
||||
type: number
|
||||
example: 20
|
||||
totalResults:
|
||||
type: number
|
||||
example: 200
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/MovieResult'
|
||||
/request:
|
||||
get:
|
||||
summary: Get all requests
|
||||
@@ -2374,7 +2508,7 @@ paths:
|
||||
crew:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/CreditCrew"
|
||||
$ref: '#/components/schemas/CreditCrew'
|
||||
id:
|
||||
type: number
|
||||
/media:
|
||||
@@ -2442,3 +2576,4 @@ paths:
|
||||
|
||||
security:
|
||||
- cookieAuth: []
|
||||
- apiKey: []
|
||||
|
||||
62
package.json
62
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "overseerr",
|
||||
"version": "1.1.0",
|
||||
"version": "1.10.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node --files --project server/tsconfig.json server/index.ts",
|
||||
@@ -9,50 +9,51 @@
|
||||
"build": "yarn build:next && yarn build:server",
|
||||
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\"",
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
"i18n:extract": "extract-messages -l=en,ja,fr -o src/i18n/locale -d en --flat true './src/**/!(*.test).{ts,tsx}'",
|
||||
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault false './src/**/!(*.test).{ts,tsx}'",
|
||||
"migration:generate": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:generate",
|
||||
"migration:create": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:create",
|
||||
"migration:run": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:run",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svgr/webpack": "^5.4.0",
|
||||
"axios": "^0.20.0",
|
||||
"@svgr/webpack": "^5.5.0",
|
||||
"axios": "^0.21.0",
|
||||
"body-parser": "^1.19.0",
|
||||
"bowser": "^2.11.0",
|
||||
"connect-typeorm": "^1.1.4",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"email-templates": "^8.0.0",
|
||||
"email-templates": "^8.0.2",
|
||||
"express": "^4.17.1",
|
||||
"express-openapi-validator": "^3.16.15",
|
||||
"express-openapi-validator": "^4.8.0",
|
||||
"express-session": "^1.17.1",
|
||||
"formik": "^2.2.1",
|
||||
"formik": "^2.2.5",
|
||||
"intl": "^1.2.5",
|
||||
"lodash": "^4.17.20",
|
||||
"next": "^10.0.3",
|
||||
"node-schedule": "^1.3.2",
|
||||
"nodemailer": "^6.4.16",
|
||||
"nookies": "^2.4.0",
|
||||
"nookies": "^2.5.0",
|
||||
"plex-api": "^5.3.1",
|
||||
"pug": "^3.0.0",
|
||||
"react": "16.13.1",
|
||||
"react-dom": "16.13.1",
|
||||
"react": "17.0.1",
|
||||
"react-dom": "17.0.1",
|
||||
"react-intersection-observer": "^8.31.0",
|
||||
"react-intl": "^5.8.5",
|
||||
"react-intl": "^5.10.6",
|
||||
"react-spring": "^8.0.27",
|
||||
"react-toast-notifications": "^2.4.0",
|
||||
"react-transition-group": "^4.4.1",
|
||||
"react-use-clipboard": "1.0.1",
|
||||
"react-use-clipboard": "1.0.2",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"sqlite3": "^5.0.0",
|
||||
"swagger-ui-express": "^4.1.4",
|
||||
"swr": "^0.3.5",
|
||||
"typeorm": "^0.2.26",
|
||||
"uuid": "^8.3.1",
|
||||
"swagger-ui-express": "^4.1.5",
|
||||
"swr": "^0.3.9",
|
||||
"typeorm": "^0.2.29",
|
||||
"uuid": "^8.3.2",
|
||||
"winston": "^3.3.3",
|
||||
"xml2js": "^0.4.23",
|
||||
"yamljs": "^0.3.0",
|
||||
"yup": "^0.29.3"
|
||||
"yup": "^0.32.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.12.8",
|
||||
@@ -68,10 +69,10 @@
|
||||
"@types/body-parser": "^1.19.0",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
"@types/email-templates": "^7.1.0",
|
||||
"@types/express": "^4.17.8",
|
||||
"@types/express": "^4.17.9",
|
||||
"@types/express-session": "^1.17.0",
|
||||
"@types/lodash": "^4.14.165",
|
||||
"@types/node": "^14.14.10",
|
||||
"@types/node": "^14.14.11",
|
||||
"@types/node-schedule": "^1.3.1",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"@types/react": "^17.0.0",
|
||||
@@ -86,10 +87,10 @@
|
||||
"@typescript-eslint/eslint-plugin": "^4.9.1",
|
||||
"@typescript-eslint/parser": "^4.9.1",
|
||||
"autoprefixer": "^9",
|
||||
"babel-plugin-react-intl": "^8.2.2",
|
||||
"babel-plugin-react-intl": "^8.2.21",
|
||||
"babel-plugin-react-intl-auto": "^3.3.0",
|
||||
"commitizen": "^4.2.1",
|
||||
"copyfiles": "^2.4.0",
|
||||
"commitizen": "^4.2.2",
|
||||
"copyfiles": "^2.4.1",
|
||||
"cz-conventional-changelog": "^3.3.0",
|
||||
"eslint": "^7.15.0",
|
||||
"eslint-config-prettier": "^7.0.0",
|
||||
@@ -104,12 +105,12 @@
|
||||
"nodemon": "^2.0.6",
|
||||
"postcss": "^7",
|
||||
"postcss-preset-env": "^6.7.0",
|
||||
"prettier": "^2.1.2",
|
||||
"prettier": "^2.2.1",
|
||||
"semantic-release": "^17.3.0",
|
||||
"semantic-release-docker": "^2.2.0",
|
||||
"tailwindcss": "npm:@tailwindcss/postcss7-compat",
|
||||
"ts-node": "^9.0.0",
|
||||
"typescript": "^4.0.2"
|
||||
"ts-node": "^9.1.1",
|
||||
"typescript": "^4.1.2"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
@@ -170,7 +171,13 @@
|
||||
"prepareCmd": "docker build -t sctx/overseerr ."
|
||||
}
|
||||
],
|
||||
"semantic-release-docker"
|
||||
"semantic-release-docker",
|
||||
[
|
||||
"@semantic-release/github",
|
||||
{
|
||||
"addReleases": "top"
|
||||
}
|
||||
]
|
||||
],
|
||||
"branches": [
|
||||
"master"
|
||||
@@ -180,7 +187,8 @@
|
||||
{
|
||||
"path": "semantic-release-docker",
|
||||
"name": "sctx/overseerr"
|
||||
}
|
||||
},
|
||||
"@semantic-release/github"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#1e2937","display":"standalone"}
|
||||
|
||||
@@ -4,10 +4,12 @@ import { getSettings } from '../lib/settings';
|
||||
export interface PlexLibraryItem {
|
||||
ratingKey: string;
|
||||
parentRatingKey?: string;
|
||||
grandparentRatingKey?: string;
|
||||
title: string;
|
||||
guid: string;
|
||||
parentGuid?: string;
|
||||
type: 'movie' | 'show' | 'season';
|
||||
grandparentGuid?: string;
|
||||
type: 'movie' | 'show' | 'season' | 'episode';
|
||||
}
|
||||
|
||||
interface PlexLibraryResponse {
|
||||
@@ -20,6 +22,7 @@ export interface PlexLibrary {
|
||||
type: 'show' | 'movie';
|
||||
key: string;
|
||||
title: string;
|
||||
agent: string;
|
||||
}
|
||||
|
||||
interface PlexLibrariesResponse {
|
||||
@@ -61,7 +64,8 @@ class PlexAPI {
|
||||
|
||||
this.plexClient = new NodePlexAPI({
|
||||
hostname: settings.plex.ip,
|
||||
post: settings.plex.port,
|
||||
port: settings.plex.port,
|
||||
https: settings.plex.useSsl,
|
||||
token: plexToken,
|
||||
authenticator: {
|
||||
authenticate: (
|
||||
@@ -119,9 +123,9 @@ class PlexAPI {
|
||||
return response.MediaContainer.Metadata[0];
|
||||
}
|
||||
|
||||
public async getRecentlyAdded(): Promise<PlexLibraryItem[]> {
|
||||
public async getRecentlyAdded(id: string): Promise<PlexLibraryItem[]> {
|
||||
const response = await this.plexClient.query<PlexLibraryResponse>(
|
||||
'/library/recentlyAdded'
|
||||
`/library/sections/${id}/recentlyAdded`
|
||||
);
|
||||
|
||||
return response.MediaContainer.Metadata;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Axios, { AxiosInstance } from 'axios';
|
||||
import logger from '../logger';
|
||||
|
||||
interface RadarrMovieOptions {
|
||||
title: string;
|
||||
@@ -75,11 +76,9 @@ class RadarrAPI {
|
||||
}
|
||||
};
|
||||
|
||||
public addMovie = async (
|
||||
options: RadarrMovieOptions
|
||||
): Promise<RadarrMovie> => {
|
||||
public addMovie = async (options: RadarrMovieOptions): Promise<void> => {
|
||||
try {
|
||||
const response = await this.axios.post<RadarrMovie>(`/movie`, {
|
||||
await this.axios.post<RadarrMovie>(`/movie`, {
|
||||
title: options.title,
|
||||
qualityProfileId: options.qualityProfileId,
|
||||
profileId: options.profileId,
|
||||
@@ -93,10 +92,15 @@ class RadarrAPI {
|
||||
searchForMovie: options.searchNow,
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[Radarr] Failed to add movie: ${e.message}`);
|
||||
logger.error(
|
||||
'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.',
|
||||
{
|
||||
label: 'Radarr',
|
||||
errorMessage: e.message,
|
||||
options,
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ class RottenTomatoes {
|
||||
|
||||
public async getTVRatings(
|
||||
name: string,
|
||||
year: number
|
||||
year?: number
|
||||
): Promise<RTRating | null> {
|
||||
try {
|
||||
const response = await this.axios.get<RTMultiSearchResponse>(
|
||||
@@ -126,9 +126,13 @@ class RottenTomatoes {
|
||||
}
|
||||
);
|
||||
|
||||
const tvshow = response.data.tvSeries.find(
|
||||
(series) => series.startYear === year
|
||||
);
|
||||
let tvshow: RTTvSearchResult | undefined = response.data.tvSeries[0];
|
||||
|
||||
if (year) {
|
||||
tvshow = response.data.tvSeries.find(
|
||||
(series) => series.startYear === year
|
||||
);
|
||||
}
|
||||
|
||||
if (!tvshow) {
|
||||
return null;
|
||||
|
||||
@@ -6,7 +6,7 @@ interface SonarrSeason {
|
||||
monitored: boolean;
|
||||
}
|
||||
|
||||
interface SonarrSeries {
|
||||
export interface SonarrSeries {
|
||||
title: string;
|
||||
sortTitle: string;
|
||||
seasonCount: number;
|
||||
@@ -33,7 +33,7 @@ interface SonarrSeries {
|
||||
tvMazeId: number;
|
||||
firstAired: string;
|
||||
lastInfoSync?: string;
|
||||
seriesType: string;
|
||||
seriesType: 'standard' | 'daily' | 'anime';
|
||||
cleanTitle: string;
|
||||
imdbId: string;
|
||||
titleSlug: string;
|
||||
@@ -76,7 +76,9 @@ interface AddSeriesOptions {
|
||||
title: string;
|
||||
profileId: number;
|
||||
seasons: number[];
|
||||
seasonFolder: boolean;
|
||||
rootFolderPath: string;
|
||||
seriesType: SonarrSeries['seriesType'];
|
||||
monitored?: boolean;
|
||||
searchNow?: boolean;
|
||||
}
|
||||
@@ -149,8 +151,10 @@ class SonarrAPI {
|
||||
monitored: false,
|
||||
}))
|
||||
),
|
||||
seasonFolder: options.seasonFolder,
|
||||
monitored: options.monitored,
|
||||
rootFolderPath: options.rootFolderPath,
|
||||
seriesType: options.seriesType,
|
||||
addOptions: {
|
||||
ignoreEpisodesWithFiles: true,
|
||||
searchForMissingEpisodes: options.searchNow,
|
||||
@@ -162,7 +166,7 @@ class SonarrAPI {
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong adding a series to Sonarr', {
|
||||
label: 'Sonarr API',
|
||||
message: e.message,
|
||||
errorMessage: e.message,
|
||||
error: e,
|
||||
});
|
||||
throw new Error('Failed to add series');
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
export const ANIME_KEYWORD_ID = 210024;
|
||||
|
||||
interface SearchOptions {
|
||||
query: string;
|
||||
page?: number;
|
||||
@@ -258,6 +260,11 @@ export interface TmdbTvDetails {
|
||||
name: string;
|
||||
origin_country: string;
|
||||
}[];
|
||||
spoken_languages: {
|
||||
english_name: string;
|
||||
iso_639_1: string;
|
||||
name: string;
|
||||
}[];
|
||||
seasons: TmdbTvSeasonResult[];
|
||||
status: string;
|
||||
type: string;
|
||||
@@ -268,6 +275,14 @@ export interface TmdbTvDetails {
|
||||
crew: TmdbCreditCrew[];
|
||||
};
|
||||
external_ids: TmdbExternalIds;
|
||||
keywords: {
|
||||
results: TmdbKeyword[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface TmdbKeyword {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TmdbPersonDetail {
|
||||
@@ -437,7 +452,10 @@ class TheMovieDb {
|
||||
}): Promise<TmdbTvDetails> => {
|
||||
try {
|
||||
const response = await this.axios.get<TmdbTvDetails>(`/tv/${tvId}`, {
|
||||
params: { language, append_to_response: 'credits,external_ids' },
|
||||
params: {
|
||||
language,
|
||||
append_to_response: 'credits,external_ids,keywords',
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
@@ -524,6 +542,32 @@ class TheMovieDb {
|
||||
}
|
||||
}
|
||||
|
||||
public async getMoviesByKeyword({
|
||||
keywordId,
|
||||
page = 1,
|
||||
language = 'en-US',
|
||||
}: {
|
||||
keywordId: number;
|
||||
page?: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbSearchMovieResponse> {
|
||||
try {
|
||||
const response = await this.axios.get<TmdbSearchMovieResponse>(
|
||||
`/keyword/${keywordId}/movies`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
language,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getTvRecommendations({
|
||||
tvId,
|
||||
page = 1,
|
||||
|
||||
@@ -8,14 +8,11 @@ import {
|
||||
UpdateDateColumn,
|
||||
getRepository,
|
||||
In,
|
||||
AfterUpdate,
|
||||
} from 'typeorm';
|
||||
import { MediaRequest } from './MediaRequest';
|
||||
import { MediaStatus, MediaType } from '../constants/media';
|
||||
import logger from '../logger';
|
||||
import Season from './Season';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
|
||||
@Entity()
|
||||
class Media {
|
||||
@@ -98,32 +95,6 @@ class Media {
|
||||
constructor(init?: Partial<Media>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
@AfterUpdate()
|
||||
private async _notifyAvailable() {
|
||||
if (this.status === MediaStatus.AVAILABLE) {
|
||||
if (this.mediaType === MediaType.MOVIE) {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const relatedRequests = await requestRepository.find({
|
||||
where: { media: this },
|
||||
});
|
||||
|
||||
if (relatedRequests.length > 0) {
|
||||
const tmdb = new TheMovieDb();
|
||||
const movie = await tmdb.getMovie({ movieId: this.tmdbId });
|
||||
|
||||
relatedRequests.forEach((request) => {
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
notifyUser: request.requestedBy,
|
||||
subject: movie.title,
|
||||
message: movie.overview,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Media;
|
||||
|
||||
@@ -15,11 +15,11 @@ import { User } from './User';
|
||||
import Media from './Media';
|
||||
import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import TheMovieDb, { ANIME_KEYWORD_ID } from '../api/themoviedb';
|
||||
import RadarrAPI from '../api/radarr';
|
||||
import logger from '../logger';
|
||||
import SeasonRequest from './SeasonRequest';
|
||||
import SonarrAPI from '../api/sonarr';
|
||||
import SonarrAPI, { SonarrSeries } from '../api/sonarr';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
|
||||
@Entity()
|
||||
@@ -36,10 +36,18 @@ export class MediaRequest {
|
||||
})
|
||||
public media: Media;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.requests, { eager: true })
|
||||
@ManyToOne(() => User, (user) => user.requests, {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
public requestedBy: User;
|
||||
|
||||
@ManyToOne(() => User, { nullable: true, cascade: true, eager: true })
|
||||
@ManyToOne(() => User, {
|
||||
nullable: true,
|
||||
cascade: true,
|
||||
eager: true,
|
||||
onDelete: 'SET NULL',
|
||||
})
|
||||
public modifiedBy?: User;
|
||||
|
||||
@CreateDateColumn()
|
||||
@@ -234,13 +242,24 @@ export class MediaRequest {
|
||||
return;
|
||||
}
|
||||
|
||||
const radarrSettings = settings.radarr.find(
|
||||
(radarr) => radarr.isDefault && !radarr.is4k
|
||||
);
|
||||
|
||||
if (!radarrSettings) {
|
||||
logger.info(
|
||||
'There is no default radarr configured. Did you set any of your Radarr servers as default?',
|
||||
{ label: 'Media Request' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tmdb = new TheMovieDb();
|
||||
const radarrSettings = settings.radarr[0];
|
||||
const radarr = new RadarrAPI({
|
||||
apiKey: radarrSettings.apiKey,
|
||||
url: `${radarrSettings.useSsl ? 'https' : 'http'}://${
|
||||
radarrSettings.hostname
|
||||
}:${radarrSettings.port}/api`,
|
||||
}:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`,
|
||||
});
|
||||
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
|
||||
|
||||
@@ -283,6 +302,18 @@ export class MediaRequest {
|
||||
return;
|
||||
}
|
||||
|
||||
const sonarrSettings = settings.sonarr.find(
|
||||
(sonarr) => sonarr.isDefault && !sonarr.is4k
|
||||
);
|
||||
|
||||
if (!sonarrSettings) {
|
||||
logger.info(
|
||||
'There is no default sonarr configured. Did you set any of your Sonarr servers as default?',
|
||||
{ label: 'Media Request' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: this.media.id },
|
||||
relations: ['requests'],
|
||||
@@ -293,12 +324,11 @@ export class MediaRequest {
|
||||
}
|
||||
|
||||
const tmdb = new TheMovieDb();
|
||||
const sonarrSettings = settings.sonarr[0];
|
||||
const sonarr = new SonarrAPI({
|
||||
apiKey: sonarrSettings.apiKey,
|
||||
url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${
|
||||
sonarrSettings.hostname
|
||||
}:${sonarrSettings.port}/api`,
|
||||
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`,
|
||||
});
|
||||
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||
|
||||
@@ -306,13 +336,32 @@ export class MediaRequest {
|
||||
throw new Error('Series was missing tvdb id');
|
||||
}
|
||||
|
||||
let seriesType: SonarrSeries['seriesType'] = 'standard';
|
||||
|
||||
// Change series type to anime if the anime keyword is present on tmdb
|
||||
if (
|
||||
series.keywords.results.some(
|
||||
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
)
|
||||
) {
|
||||
seriesType = 'anime';
|
||||
}
|
||||
|
||||
// Run this asynchronously so we don't wait for it on the UI side
|
||||
sonarr.addSeries({
|
||||
profileId: sonarrSettings.activeProfileId,
|
||||
rootFolderPath: sonarrSettings.activeDirectory,
|
||||
profileId:
|
||||
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
|
||||
? sonarrSettings.activeAnimeProfileId
|
||||
: sonarrSettings.activeProfileId,
|
||||
rootFolderPath:
|
||||
seriesType === 'anime' && sonarrSettings.activeAnimeDirectory
|
||||
? sonarrSettings.activeAnimeDirectory
|
||||
: sonarrSettings.activeDirectory,
|
||||
title: series.name,
|
||||
tvdbid: series.external_ids.tvdb_id,
|
||||
seasons: this.seasons.map((season) => season.seasonNumber),
|
||||
seasonFolder: sonarrSettings.enableSeasonFolders,
|
||||
seriesType,
|
||||
monitored: true,
|
||||
searchNow: true,
|
||||
});
|
||||
|
||||
@@ -5,15 +5,9 @@ import {
|
||||
ManyToOne,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
AfterInsert,
|
||||
AfterUpdate,
|
||||
getRepository,
|
||||
} from 'typeorm';
|
||||
import { MediaStatus } from '../constants/media';
|
||||
import Media from './Media';
|
||||
import logger from '../logger';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
|
||||
@Entity()
|
||||
class Season {
|
||||
@@ -38,60 +32,6 @@ class Season {
|
||||
constructor(init?: Partial<Season>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
@AfterInsert()
|
||||
@AfterUpdate()
|
||||
private async _sendSeasonAvailableNotification() {
|
||||
if (this.status === MediaStatus.AVAILABLE) {
|
||||
try {
|
||||
const lazyMedia = await this.media;
|
||||
const tmdb = new TheMovieDb();
|
||||
const mediaRepository = getRepository(Media);
|
||||
const media = await mediaRepository.findOneOrFail({
|
||||
where: { id: lazyMedia.id },
|
||||
relations: ['requests'],
|
||||
});
|
||||
|
||||
const availableSeasons = media.seasons.map(
|
||||
(season) => season.seasonNumber
|
||||
);
|
||||
|
||||
const request = media.requests.find(
|
||||
(request) =>
|
||||
// Check if the season is complete AND it contains the current season that was just marked available
|
||||
request.seasons.every((season) =>
|
||||
availableSeasons.includes(season.seasonNumber)
|
||||
) &&
|
||||
request.seasons.some(
|
||||
(season) => season.seasonNumber === this.seasonNumber
|
||||
)
|
||||
);
|
||||
|
||||
if (request) {
|
||||
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
subject: tv.name,
|
||||
message: tv.overview,
|
||||
notifyUser: request.requestedBy,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||
extra: [
|
||||
{
|
||||
name: 'Seasons',
|
||||
value: request.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong sending season available notice', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Season;
|
||||
|
||||
@@ -9,7 +9,7 @@ import session, { Store } from 'express-session';
|
||||
import { TypeormStore } from 'connect-typeorm/out';
|
||||
import YAML from 'yamljs';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import { OpenApiValidator } from 'express-openapi-validator';
|
||||
import * as OpenApiValidator from 'express-openapi-validator';
|
||||
import { Session } from './entity/Session';
|
||||
import { getSettings } from './lib/settings';
|
||||
import logger from './logger';
|
||||
@@ -17,9 +17,11 @@ import { startJobs } from './job/schedule';
|
||||
import notificationManager from './lib/notifications';
|
||||
import DiscordAgent from './lib/notifications/agents/discord';
|
||||
import EmailAgent from './lib/notifications/agents/email';
|
||||
import { getAppVersion } from './utils/appVersion';
|
||||
|
||||
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
|
||||
|
||||
logger.info(`Starting Overseerr version ${getAppVersion()}`);
|
||||
const dev = process.env.NODE_ENV !== 'production';
|
||||
const app = next({ dev });
|
||||
const handle = app.getRequestHandler();
|
||||
@@ -61,11 +63,12 @@ app
|
||||
);
|
||||
const apiDocs = YAML.load(API_SPEC_PATH);
|
||||
server.use('/api-docs', swaggerUi.serve, swaggerUi.setup(apiDocs));
|
||||
await new OpenApiValidator({
|
||||
apiSpec: API_SPEC_PATH,
|
||||
validateRequests: true,
|
||||
validateResponses: true,
|
||||
}).install(server);
|
||||
server.use(
|
||||
OpenApiValidator.middleware({
|
||||
apiSpec: API_SPEC_PATH,
|
||||
validateRequests: true,
|
||||
})
|
||||
);
|
||||
/**
|
||||
* This is a workaround to convert dates to strings before they are validated by
|
||||
* OpenAPI validator. Otherwise, they are treated as objects instead of strings
|
||||
@@ -97,12 +100,21 @@ app
|
||||
}
|
||||
);
|
||||
|
||||
const port = Number(process.env.PORT) || 3000;
|
||||
server.listen(port, () => {
|
||||
logger.info(`Server ready on port ${port}`, {
|
||||
label: 'SERVER',
|
||||
const port = Number(process.env.PORT) || 5055;
|
||||
const host = process.env.HOST;
|
||||
if (host) {
|
||||
server.listen(port, host, () => {
|
||||
logger.info(`Server ready on ${host} port ${port}`, {
|
||||
label: 'Server',
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
server.listen(port, () => {
|
||||
logger.info(`Server ready on port ${port}`, {
|
||||
label: 'Server',
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error(err.stack);
|
||||
|
||||
7
server/interfaces/api/personInterfaces.ts
Normal file
7
server/interfaces/api/personInterfaces.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { PersonCreditCast, PersonCreditCrew } from '../../models/Person';
|
||||
|
||||
export interface PersonCombinedCreditsResponse {
|
||||
id: number;
|
||||
cast: PersonCreditCast[];
|
||||
crew: PersonCreditCrew[];
|
||||
}
|
||||
6
server/interfaces/api/settingsInterfaces.ts
Normal file
6
server/interfaces/api/settingsInterfaces.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface SettingsAboutResponse {
|
||||
version: string;
|
||||
totalRequests: number;
|
||||
totalMediaItems: number;
|
||||
tz?: string;
|
||||
}
|
||||
@@ -7,8 +7,10 @@ import { MediaStatus, MediaType } from '../../constants/media';
|
||||
import logger from '../../logger';
|
||||
import { getSettings, Library } from '../../lib/settings';
|
||||
import Season from '../../entity/Season';
|
||||
import { uniqWith } from 'lodash';
|
||||
|
||||
const BUNDLE_SIZE = 10;
|
||||
const BUNDLE_SIZE = 20;
|
||||
const UPDATE_RATE = 4 * 1000;
|
||||
|
||||
const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
|
||||
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
|
||||
@@ -51,64 +53,81 @@ class JobPlexSync {
|
||||
|
||||
private async processMovie(plexitem: PlexLibraryItem) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
if (plexitem.guid.match(plexRegex)) {
|
||||
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
|
||||
const newMedia = new Media();
|
||||
try {
|
||||
if (plexitem.guid.match(plexRegex)) {
|
||||
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
|
||||
const newMedia = new Media();
|
||||
|
||||
metadata.Guid.forEach((ref) => {
|
||||
if (ref.id.match(imdbRegex)) {
|
||||
newMedia.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined;
|
||||
} else if (ref.id.match(tmdbRegex)) {
|
||||
const tmdbMatch = ref.id.match(tmdbRegex)?.[1];
|
||||
newMedia.tmdbId = Number(tmdbMatch);
|
||||
if (!metadata.Guid) {
|
||||
logger.debug('No Guid metadata for this title. Skipping', {
|
||||
label: 'Plex Sync',
|
||||
ratingKey: plexitem.ratingKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const existing = await this.getExisting(newMedia.tmdbId);
|
||||
|
||||
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'
|
||||
);
|
||||
} else {
|
||||
newMedia.status = MediaStatus.AVAILABLE;
|
||||
newMedia.mediaType = MediaType.MOVIE;
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${plexitem.title}`);
|
||||
}
|
||||
} else {
|
||||
const matchedid = plexitem.guid.match(/imdb:\/\/(tt[0-9]+)/);
|
||||
|
||||
if (matchedid?.[1]) {
|
||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
||||
imdbId: matchedid[1],
|
||||
metadata.Guid.forEach((ref) => {
|
||||
if (ref.id.match(imdbRegex)) {
|
||||
newMedia.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined;
|
||||
} else if (ref.id.match(tmdbRegex)) {
|
||||
const tmdbMatch = ref.id.match(tmdbRegex)?.[1];
|
||||
newMedia.tmdbId = Number(tmdbMatch);
|
||||
}
|
||||
});
|
||||
|
||||
const existing = await this.getExisting(tmdbMovie.id);
|
||||
const existing = await this.getExisting(newMedia.tmdbId);
|
||||
|
||||
if (existing && existing.status === MediaStatus.AVAILABLE) {
|
||||
this.log(`Title exists and is already available ${plexitem.title}`);
|
||||
this.log(`Title exists and is already available ${metadata.title}`);
|
||||
} else if (existing && existing.status !== MediaStatus.AVAILABLE) {
|
||||
existing.status = MediaStatus.AVAILABLE;
|
||||
await mediaRepository.save(existing);
|
||||
mediaRepository.save(existing);
|
||||
this.log(
|
||||
`Request for ${plexitem.title} exists. Setting status AVAILABLE`,
|
||||
`Request for ${metadata.title} exists. Setting status AVAILABLE`,
|
||||
'info'
|
||||
);
|
||||
} else if (tmdbMovie) {
|
||||
const newMedia = new Media();
|
||||
newMedia.imdbId = tmdbMovie.external_ids.imdb_id;
|
||||
newMedia.tmdbId = tmdbMovie.id;
|
||||
} else {
|
||||
newMedia.status = MediaStatus.AVAILABLE;
|
||||
newMedia.mediaType = MediaType.MOVIE;
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${tmdbMovie.title}`);
|
||||
this.log(`Saved ${plexitem.title}`);
|
||||
}
|
||||
} else {
|
||||
const matchedid = plexitem.guid.match(/imdb:\/\/(tt[0-9]+)/);
|
||||
|
||||
if (matchedid?.[1]) {
|
||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
||||
imdbId: matchedid[1],
|
||||
});
|
||||
|
||||
const existing = await this.getExisting(tmdbMovie.id);
|
||||
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 (tmdbMovie) {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.log(
|
||||
`Failed to process plex item. ratingKey: ${
|
||||
plexitem.parentRatingKey ?? plexitem.ratingKey
|
||||
}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +138,9 @@ class JobPlexSync {
|
||||
|
||||
try {
|
||||
const metadata = await this.plexClient.getMetadata(
|
||||
plexitem.parentRatingKey ?? plexitem.ratingKey,
|
||||
plexitem.grandparentRatingKey ??
|
||||
plexitem.parentRatingKey ??
|
||||
plexitem.ratingKey,
|
||||
{ includeChildren: true }
|
||||
);
|
||||
if (metadata.guid.match(tvdbRegex)) {
|
||||
@@ -222,9 +243,15 @@ class JobPlexSync {
|
||||
} catch (e) {
|
||||
this.log(
|
||||
`Failed to process plex item. ratingKey: ${
|
||||
plexitem.parentRatingKey ?? plexitem.ratingKey
|
||||
plexitem.grandparentRatingKey ??
|
||||
plexitem.parentRatingKey ??
|
||||
plexitem.ratingKey
|
||||
}`,
|
||||
'error'
|
||||
'error',
|
||||
{
|
||||
errorMessage: e.message,
|
||||
plexitem,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -234,7 +261,11 @@ class JobPlexSync {
|
||||
slicedItems.map(async (plexitem) => {
|
||||
if (plexitem.type === 'movie') {
|
||||
await this.processMovie(plexitem);
|
||||
} else if (plexitem.type === 'show') {
|
||||
} else if (
|
||||
plexitem.type === 'show' ||
|
||||
plexitem.type === 'episode' ||
|
||||
plexitem.type === 'season'
|
||||
) {
|
||||
await this.processShow(plexitem);
|
||||
}
|
||||
})
|
||||
@@ -253,23 +284,24 @@ class JobPlexSync {
|
||||
this.progress = start;
|
||||
await this.processItems(slicedItems);
|
||||
|
||||
await new Promise((resolve) =>
|
||||
await new Promise<void>((resolve) =>
|
||||
setTimeout(async () => {
|
||||
await this.loop({
|
||||
start: start + BUNDLE_SIZE,
|
||||
end: end + BUNDLE_SIZE,
|
||||
});
|
||||
resolve();
|
||||
}, 5000)
|
||||
}, UPDATE_RATE)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private log(
|
||||
message: string,
|
||||
level: 'info' | 'error' | 'debug' = 'debug'
|
||||
level: 'info' | 'error' | 'debug' = 'debug',
|
||||
optional?: Record<string, unknown>
|
||||
): void {
|
||||
logger[level](message, { label: 'Plex Sync' });
|
||||
logger[level](message, { label: 'Plex Sync', ...optional });
|
||||
}
|
||||
|
||||
public async run(): Promise<void> {
|
||||
@@ -283,20 +315,40 @@ class JobPlexSync {
|
||||
});
|
||||
|
||||
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
|
||||
if (this.isRecentOnly) {
|
||||
this.currentLibrary = {
|
||||
id: '0',
|
||||
name: 'Recently Added',
|
||||
enabled: true,
|
||||
};
|
||||
this.log(`Beginning to process recently added`, 'info');
|
||||
this.items = await this.plexClient.getRecentlyAdded();
|
||||
await this.loop();
|
||||
} else {
|
||||
this.libraries = settings.plex.libraries.filter(
|
||||
(library) => library.enabled
|
||||
);
|
||||
|
||||
this.libraries = settings.plex.libraries.filter(
|
||||
(library) => library.enabled
|
||||
);
|
||||
|
||||
if (this.isRecentOnly) {
|
||||
for (const library of this.libraries) {
|
||||
this.currentLibrary = library;
|
||||
this.log(
|
||||
`Beginning to process recently added for library: ${library.name}`,
|
||||
'info'
|
||||
);
|
||||
const libraryItems = await this.plexClient.getRecentlyAdded(
|
||||
library.id
|
||||
);
|
||||
|
||||
// Bundle items up by rating keys
|
||||
this.items = uniqWith(libraryItems, (mediaA, mediaB) => {
|
||||
if (mediaA.grandparentRatingKey && mediaB.grandparentRatingKey) {
|
||||
return (
|
||||
mediaA.grandparentRatingKey === mediaB.grandparentRatingKey
|
||||
);
|
||||
}
|
||||
|
||||
if (mediaA.parentRatingKey && mediaB.parentRatingKey) {
|
||||
return mediaA.parentRatingKey === mediaB.parentRatingKey;
|
||||
}
|
||||
|
||||
return mediaA.ratingKey === mediaB.ratingKey;
|
||||
});
|
||||
|
||||
await this.loop();
|
||||
}
|
||||
} else {
|
||||
for (const library of this.libraries) {
|
||||
this.currentLibrary = library;
|
||||
this.log(`Beginning to process library: ${library.name}`, 'info');
|
||||
|
||||
@@ -13,7 +13,7 @@ export const startJobs = (): void => {
|
||||
// Run recently added plex sync every 5 minutes
|
||||
scheduledJobs.push({
|
||||
name: 'Plex Recently Added Sync',
|
||||
job: schedule.scheduleJob('0 */10 * * * *', () => {
|
||||
job: schedule.scheduleJob('0 */5 * * * *', () => {
|
||||
logger.info('Starting scheduled job: Plex Recently Added Sync', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
@@ -23,7 +23,7 @@ export const startJobs = (): void => {
|
||||
// Run full plex sync every 6 hours
|
||||
scheduledJobs.push({
|
||||
name: 'Plex Full Library Sync',
|
||||
job: schedule.scheduleJob('0 0 */6 * * *', () => {
|
||||
job: schedule.scheduleJob('0 0 3 * * *', () => {
|
||||
logger.info('Starting scheduled job: Plex Full Sync', { label: 'Jobs' });
|
||||
jobPlexFullSync.run();
|
||||
}),
|
||||
|
||||
@@ -29,10 +29,13 @@ class EmailAgent implements NotificationAgent {
|
||||
host: emailSettings.smtpHost,
|
||||
port: emailSettings.smtpPort,
|
||||
secure: emailSettings.secure,
|
||||
auth: {
|
||||
user: emailSettings.authUser,
|
||||
pass: emailSettings.authPass,
|
||||
},
|
||||
auth:
|
||||
emailSettings.authUser && emailSettings.authPass
|
||||
? {
|
||||
user: emailSettings.authUser,
|
||||
pass: emailSettings.authPass,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -135,7 +138,7 @@ class EmailAgent implements NotificationAgent {
|
||||
to: payload.notifyUser.email,
|
||||
},
|
||||
locals: {
|
||||
body: 'Your requsested media is now available!',
|
||||
body: 'Your requested media is now available!',
|
||||
mediaName: payload.subject,
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface PlexSettings {
|
||||
machineId?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
useSsl?: boolean;
|
||||
libraries: Library[];
|
||||
}
|
||||
|
||||
@@ -100,7 +101,7 @@ class Settings {
|
||||
|
||||
constructor(initialSettings?: AllSettings) {
|
||||
this.data = {
|
||||
clientId: '',
|
||||
clientId: uuidv4(),
|
||||
main: {
|
||||
apiKey: '',
|
||||
applicationUrl: '',
|
||||
@@ -109,6 +110,7 @@ class Settings {
|
||||
name: '',
|
||||
ip: '127.0.0.1',
|
||||
port: 32400,
|
||||
useSsl: false,
|
||||
libraries: [],
|
||||
},
|
||||
radarr: [],
|
||||
@@ -211,7 +213,7 @@ class Settings {
|
||||
}
|
||||
|
||||
private generateApiKey(): string {
|
||||
return Buffer.from(`${Date.now()}${this.clientId}`).toString('base64');
|
||||
return Buffer.from(`${Date.now()}${uuidv4()})`).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,10 +224,10 @@ class Settings {
|
||||
* @param overrideSettings If passed in, will override all existing settings with these
|
||||
* values
|
||||
*/
|
||||
public load(overrideSettings?: AllSettings): AllSettings {
|
||||
public load(overrideSettings?: AllSettings): Settings {
|
||||
if (overrideSettings) {
|
||||
this.data = overrideSettings;
|
||||
return this.data;
|
||||
return this;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(SETTINGS_PATH)) {
|
||||
@@ -237,7 +239,7 @@ class Settings {
|
||||
this.data = merge(this.data, JSON.parse(data));
|
||||
this.save();
|
||||
}
|
||||
return this.data;
|
||||
return this;
|
||||
}
|
||||
|
||||
public save(): void {
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
import { getRepository } from 'typeorm';
|
||||
import { User } from '../entity/User';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import { getSettings } from '../lib/settings';
|
||||
|
||||
export const checkUser: Middleware = async (req, _res, next) => {
|
||||
if (req.session?.userId) {
|
||||
const settings = getSettings();
|
||||
if (req.header('X-API-Key') === settings.main.apiKey) {
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
let userId = 1; // Work on original administrator account
|
||||
|
||||
// If a User ID is provided, we will act on that users behalf
|
||||
if (req.header('X-API-User')) {
|
||||
userId = Number(req.header('X-API-User'));
|
||||
}
|
||||
const user = await userRepository.findOne({ where: { id: userId } });
|
||||
|
||||
if (user) {
|
||||
req.user = user;
|
||||
}
|
||||
} else if (req.session?.userId) {
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
const user = await userRepository.findOne({
|
||||
@@ -24,7 +40,7 @@ export const isAuthenticated = (
|
||||
if (!req.user || !req.user.hasPermission(permissions ?? 0)) {
|
||||
res.status(403).json({
|
||||
status: 403,
|
||||
error: 'You do not have permisson to access this endpoint',
|
||||
error: 'You do not have permission to access this endpoint',
|
||||
});
|
||||
} else {
|
||||
next();
|
||||
|
||||
20
server/migration/1607928251245-DropImdbIdConstraint.ts
Normal file
20
server/migration/1607928251245-DropImdbIdConstraint.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { MigrationInterface, QueryRunner, TableUnique } from 'typeorm';
|
||||
|
||||
export class DropImdbIdConstraint1607928251245 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropUniqueConstraint(
|
||||
'media',
|
||||
'UQ_7ff2d11f6a83cb52386eaebe74b'
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createUniqueConstraint(
|
||||
'media',
|
||||
new TableUnique({
|
||||
name: 'UQ_7ff2d11f6a83cb52386eaebe74b',
|
||||
columnNames: ['imdbId'],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUserRequestDeleteCascades1608219049304
|
||||
implements MigrationInterface {
|
||||
name = 'AddUserRequestDeleteCascades1608219049304';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") 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_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE NO ACTION 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"`);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
TmdbPersonCreditCrew,
|
||||
TmdbPersonDetail,
|
||||
} from '../api/themoviedb';
|
||||
import Media from '../entity/Media';
|
||||
|
||||
export interface PersonDetail {
|
||||
id: number;
|
||||
@@ -42,6 +43,7 @@ export interface PersonCredit {
|
||||
title: string;
|
||||
adult: boolean;
|
||||
releaseDate: string;
|
||||
mediaInfo?: Media;
|
||||
}
|
||||
|
||||
export interface PersonCreditCast extends PersonCredit {
|
||||
@@ -76,7 +78,8 @@ export const mapPersonDetails = (person: TmdbPersonDetail): PersonDetail => ({
|
||||
});
|
||||
|
||||
export const mapCastCredits = (
|
||||
cast: TmdbPersonCreditCast
|
||||
cast: TmdbPersonCreditCast,
|
||||
media?: Media
|
||||
): PersonCreditCast => ({
|
||||
id: cast.id,
|
||||
originalLanguage: cast.original_language,
|
||||
@@ -100,10 +103,12 @@ export const mapCastCredits = (
|
||||
adult: cast.adult,
|
||||
releaseDate: cast.release_date,
|
||||
character: cast.character,
|
||||
mediaInfo: media,
|
||||
});
|
||||
|
||||
export const mapCrewCredits = (
|
||||
crew: TmdbPersonCreditCrew
|
||||
crew: TmdbPersonCreditCrew,
|
||||
media?: Media
|
||||
): PersonCreditCrew => ({
|
||||
id: crew.id,
|
||||
originalLanguage: crew.original_language,
|
||||
@@ -128,4 +133,5 @@ export const mapCrewCredits = (
|
||||
releaseDate: crew.release_date,
|
||||
department: crew.department,
|
||||
job: crew.job,
|
||||
mediaInfo: media,
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
mapCrew,
|
||||
ExternalIds,
|
||||
mapExternalIds,
|
||||
Keyword,
|
||||
} from './common';
|
||||
import {
|
||||
TmdbTvEpisodeResult,
|
||||
@@ -45,6 +46,12 @@ export interface SeasonWithEpisodes extends Season {
|
||||
externalIds: ExternalIds;
|
||||
}
|
||||
|
||||
interface SpokenLanguage {
|
||||
englishName: string;
|
||||
iso_639_1: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TvDetails {
|
||||
id: number;
|
||||
backdropPath?: string;
|
||||
@@ -56,7 +63,7 @@ export interface TvDetails {
|
||||
profilePath?: string;
|
||||
}[];
|
||||
episodeRunTime: number[];
|
||||
firstAirDate: string;
|
||||
firstAirDate?: string;
|
||||
genres: Genre[];
|
||||
homepage: string;
|
||||
inProduction: boolean;
|
||||
@@ -74,6 +81,7 @@ export interface TvDetails {
|
||||
overview: string;
|
||||
popularity: number;
|
||||
productionCompanies: ProductionCompany[];
|
||||
spokenLanguages: SpokenLanguage[];
|
||||
seasons: Season[];
|
||||
status: string;
|
||||
type: string;
|
||||
@@ -84,6 +92,7 @@ export interface TvDetails {
|
||||
crew: Crew[];
|
||||
};
|
||||
externalIds: ExternalIds;
|
||||
keywords: Keyword[];
|
||||
mediaInfo?: Media;
|
||||
}
|
||||
|
||||
@@ -161,6 +170,11 @@ export const mapTvDetails = (
|
||||
originCountry: company.origin_country,
|
||||
logoPath: company.logo_path,
|
||||
})),
|
||||
spokenLanguages: show.spoken_languages.map((language) => ({
|
||||
englishName: language.english_name,
|
||||
iso_639_1: language.iso_639_1,
|
||||
name: language.name,
|
||||
})),
|
||||
seasons: show.seasons.map(mapSeasonResult),
|
||||
status: show.status,
|
||||
type: show.type,
|
||||
@@ -179,5 +193,9 @@ export const mapTvDetails = (
|
||||
crew: show.credits.crew.map(mapCrew),
|
||||
},
|
||||
externalIds: mapExternalIds(show.external_ids),
|
||||
keywords: show.keywords.results.map((keyword) => ({
|
||||
id: keyword.id,
|
||||
name: keyword.name,
|
||||
})),
|
||||
mediaInfo: media,
|
||||
});
|
||||
|
||||
@@ -11,6 +11,11 @@ export interface ProductionCompany {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Keyword {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Genre {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
@@ -14,7 +14,7 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => {
|
||||
return res.status(500).json({
|
||||
status: 500,
|
||||
error:
|
||||
'Requsted user endpoint withuot valid authenticated user in session',
|
||||
'Requested user endpoint without valid authenticated user in session',
|
||||
});
|
||||
}
|
||||
const user = await userRepository.findOneOrFail({
|
||||
@@ -24,7 +24,7 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => {
|
||||
return res.status(200).json(user.filter());
|
||||
});
|
||||
|
||||
authRoutes.post('/login', async (req, res) => {
|
||||
authRoutes.post('/login', async (req, res, next) => {
|
||||
const userRepository = getRepository(User);
|
||||
const body = req.body as { authToken?: string };
|
||||
|
||||
@@ -86,6 +86,22 @@ authRoutes.post('/login', async (req, res) => {
|
||||
avatar: account.thumb,
|
||||
});
|
||||
await userRepository.save(user);
|
||||
} else {
|
||||
logger.info(
|
||||
'Failed login attempt from user without access to plex server',
|
||||
{
|
||||
label: 'Auth',
|
||||
account: {
|
||||
...account,
|
||||
authentication_token: '__REDACTED__',
|
||||
authToken: '__REDACTED__',
|
||||
},
|
||||
}
|
||||
);
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You do not have access to this Plex server',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,9 +113,10 @@ authRoutes.post('/login', async (req, res) => {
|
||||
return res.status(200).json(user?.filter() ?? {});
|
||||
} catch (e) {
|
||||
logger.error(e.message, { label: 'Auth' });
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: 'Something went wrong. Is your auth token valid?' });
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Something went wrong. Is your auth token valid?',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -121,4 +121,36 @@ discoverRoutes.get('/trending', async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
discoverRoutes.get<{ keywordId: string }>(
|
||||
'/keyword/:keywordId/movies',
|
||||
async (req, res) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
const data = await tmdb.getMoviesByKeyword({
|
||||
keywordId: Number(req.params.keywordId),
|
||||
page: Number(req.query.page),
|
||||
language: req.query.language as string,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
page: data.page,
|
||||
totalPages: data.total_pages,
|
||||
totalResults: data.total_results,
|
||||
results: data.results.map((result) =>
|
||||
mapMovieResult(
|
||||
result,
|
||||
media.find(
|
||||
(req) =>
|
||||
req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default discoverRoutes;
|
||||
|
||||
@@ -12,8 +12,8 @@ const mediaRoutes = Router();
|
||||
mediaRoutes.get('/', async (req, res, next) => {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const pageSize = Number(req.query.take) ?? 20;
|
||||
const skip = Number(req.query.skip) ?? 0;
|
||||
const pageSize = req.query.take ? Number(req.query.take) : 20;
|
||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||
|
||||
let statusFilter:
|
||||
| MediaStatus
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import Media from '../entity/Media';
|
||||
import logger from '../logger';
|
||||
import {
|
||||
mapCastCredits,
|
||||
@@ -32,9 +33,27 @@ personRoutes.get('/:id/combined_credits', async (req, res) => {
|
||||
language: req.query.language as string,
|
||||
});
|
||||
|
||||
const castMedia = await Media.getRelatedMedia(
|
||||
combinedCredits.cast.map((result) => result.id)
|
||||
);
|
||||
|
||||
const crewMedia = await Media.getRelatedMedia(
|
||||
combinedCredits.crew.map((result) => result.id)
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
cast: combinedCredits.cast.map((result) => mapCastCredits(result)),
|
||||
crew: combinedCredits.crew.map((result) => mapCrewCredits(result)),
|
||||
cast: combinedCredits.cast.map((result) =>
|
||||
mapCastCredits(
|
||||
result,
|
||||
castMedia.find((med) => med.tmdbId === result.id)
|
||||
)
|
||||
),
|
||||
crew: combinedCredits.crew.map((result) =>
|
||||
mapCrewCredits(
|
||||
result,
|
||||
crewMedia.find((med) => med.tmdbId === result.id)
|
||||
)
|
||||
),
|
||||
id: combinedCredits.id,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,12 +15,12 @@ const requestRoutes = Router();
|
||||
requestRoutes.get('/', async (req, res, next) => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
try {
|
||||
const pageSize = Number(req.query.take) ?? 20;
|
||||
const skip = Number(req.query.skip) ?? 0;
|
||||
const pageSize = req.query.take ? Number(req.query.take) : 20;
|
||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||
|
||||
let statusFilter:
|
||||
| MediaRequestStatus
|
||||
| FindOperator<MediaRequestStatus>
|
||||
| FindOperator<string | MediaRequestStatus>
|
||||
| undefined = undefined;
|
||||
|
||||
switch (req.query.filter) {
|
||||
|
||||
@@ -17,19 +17,34 @@ import { scheduledJobs } from '../job/schedule';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import { isAuthenticated } from '../middleware/auth';
|
||||
import { merge } from 'lodash';
|
||||
import Media from '../entity/Media';
|
||||
import { MediaRequest } from '../entity/MediaRequest';
|
||||
import { getAppVersion } from '../utils/appVersion';
|
||||
import { SettingsAboutResponse } from '../interfaces/api/settingsInterfaces';
|
||||
|
||||
const settingsRoutes = Router();
|
||||
|
||||
settingsRoutes.get('/main', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
if (!req.user?.hasPermission(Permission.ADMIN)) {
|
||||
return res.status(200).json({
|
||||
applicationUrl: settings.main.applicationUrl,
|
||||
} as Partial<MainSettings>);
|
||||
const filteredMainSettings = (
|
||||
user: User,
|
||||
main: MainSettings
|
||||
): Partial<MainSettings> => {
|
||||
if (!user?.hasPermission(Permission.ADMIN)) {
|
||||
return {
|
||||
applicationUrl: main.applicationUrl,
|
||||
};
|
||||
}
|
||||
|
||||
res.status(200).json(settings.main);
|
||||
return main;
|
||||
};
|
||||
|
||||
settingsRoutes.get('/main', (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
|
||||
if (!req.user) {
|
||||
return next({ status: 500, message: 'User missing from request' });
|
||||
}
|
||||
|
||||
res.status(200).json(filteredMainSettings(req.user, settings.main));
|
||||
});
|
||||
|
||||
settingsRoutes.post('/main', (req, res) => {
|
||||
@@ -41,6 +56,18 @@ settingsRoutes.post('/main', (req, res) => {
|
||||
return res.status(200).json(settings.main);
|
||||
});
|
||||
|
||||
settingsRoutes.get('/main/regenerate', (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const main = settings.regenerateApiKey();
|
||||
|
||||
if (!req.user) {
|
||||
return next({ status: 500, message: 'User missing from request' });
|
||||
}
|
||||
|
||||
return res.status(200).json(filteredMainSettings(req.user, main));
|
||||
});
|
||||
|
||||
settingsRoutes.get('/plex', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
@@ -91,17 +118,22 @@ settingsRoutes.get('/plex/library', async (req, res) => {
|
||||
|
||||
const libraries = await plexapi.getLibraries();
|
||||
|
||||
const newLibraries: Library[] = libraries.map((library) => {
|
||||
const existing = settings.plex.libraries.find(
|
||||
(l) => l.id === library.key
|
||||
);
|
||||
const newLibraries: Library[] = libraries
|
||||
// Remove libraries that are not movie or show
|
||||
.filter((library) => library.type === 'movie' || library.type === 'show')
|
||||
// Remove libraries that do not have a metadata agent set (usually personal video libraries)
|
||||
.filter((library) => library.agent !== 'com.plexapp.agents.none')
|
||||
.map((library) => {
|
||||
const existing = settings.plex.libraries.find(
|
||||
(l) => l.id === library.key && l.name === library.title
|
||||
);
|
||||
|
||||
return {
|
||||
id: library.key,
|
||||
name: library.title,
|
||||
enabled: existing?.enabled ?? false,
|
||||
};
|
||||
});
|
||||
return {
|
||||
id: library.key,
|
||||
name: library.title,
|
||||
enabled: existing?.enabled ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
settings.plex.libraries = newLibraries;
|
||||
}
|
||||
@@ -431,4 +463,19 @@ settingsRoutes.post('/notifications/email', (req, res) => {
|
||||
res.status(200).json(settings.notifications.agents.email);
|
||||
});
|
||||
|
||||
settingsRoutes.get('/about', async (req, res) => {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const mediaRequestRepository = getRepository(MediaRequest);
|
||||
|
||||
const totalMediaItems = await mediaRepository.count();
|
||||
const totalRequests = await mediaRequestRepository.count();
|
||||
|
||||
return res.status(200).json({
|
||||
version: getAppVersion(),
|
||||
totalMediaItems,
|
||||
totalRequests,
|
||||
tz: process.env.TZ,
|
||||
} as SettingsAboutResponse);
|
||||
});
|
||||
|
||||
export default settingsRoutes;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { mapTvDetails, mapSeasonWithEpisodes } from '../models/Tv';
|
||||
import { mapTvResult } from '../models/Search';
|
||||
import Media from '../entity/Media';
|
||||
import RottenTomatoes from '../api/rottentomatoes';
|
||||
import logger from '../logger';
|
||||
|
||||
const tvRoutes = Router();
|
||||
|
||||
@@ -19,6 +20,10 @@ tvRoutes.get('/:id', async (req, res, next) => {
|
||||
|
||||
return res.status(200).json(mapTvDetails(tv, media));
|
||||
} catch (e) {
|
||||
logger.error('Failed to get tv show', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
return next({ status: 404, message: 'TV Show does not exist' });
|
||||
}
|
||||
});
|
||||
@@ -101,7 +106,7 @@ tvRoutes.get('/:id/ratings', async (req, res, next) => {
|
||||
|
||||
const rtratings = await rtapi.getTVRatings(
|
||||
tv.name,
|
||||
Number(tv.first_air_date.slice(0, 4))
|
||||
tv.first_air_date ? Number(tv.first_air_date.slice(0, 4)) : undefined
|
||||
);
|
||||
|
||||
if (!rtratings) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { MediaRequest } from '../entity/MediaRequest';
|
||||
import { User } from '../entity/User';
|
||||
import { hasPermission, Permission } from '../lib/permissions';
|
||||
import logger from '../logger';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -94,13 +96,49 @@ router.delete<{ id: string }>('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
const user = await userRepository.findOneOrFail({
|
||||
const user = await userRepository.findOne({
|
||||
where: { id: Number(req.params.id) },
|
||||
relations: ['requests'],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return next({ status: 404, message: 'User not found' });
|
||||
}
|
||||
|
||||
if (user.id === 1) {
|
||||
return next({ status: 405, message: 'This account cannot be deleted.' });
|
||||
}
|
||||
|
||||
if (user.hasPermission(Permission.ADMIN)) {
|
||||
return next({
|
||||
status: 405,
|
||||
message: 'You cannot delete users with administrative privileges.',
|
||||
});
|
||||
}
|
||||
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
/**
|
||||
* Requests are usually deleted through a cascade constraint. Those however, do
|
||||
* not trigger the removal event so listeners to not run and the parent Media
|
||||
* will not be updated back to unknown for titles that were still pending. So
|
||||
* we manually remove all requests from the user here so the parent media's
|
||||
* properly reflect the change.
|
||||
*/
|
||||
await requestRepository.remove(user.requests);
|
||||
|
||||
await userRepository.delete(user.id);
|
||||
return res.status(200).json(user.filter());
|
||||
} catch (e) {
|
||||
next({ status: 404, message: 'User not found' });
|
||||
logger.error('Something went wrong deleting a user', {
|
||||
label: 'API',
|
||||
userId: req.params.id,
|
||||
errorMessage: e.message,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Something went wrong deleting the user',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
112
server/subscriber/MediaSubscriber.ts
Normal file
112
server/subscriber/MediaSubscriber.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
EntitySubscriberInterface,
|
||||
EventSubscriber,
|
||||
getRepository,
|
||||
UpdateEvent,
|
||||
} from 'typeorm';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import { MediaStatus, MediaType } from '../constants/media';
|
||||
import Media from '../entity/Media';
|
||||
import { MediaRequest } from '../entity/MediaRequest';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
|
||||
@EventSubscriber()
|
||||
export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
private async notifyAvailableMovie(entity: Media) {
|
||||
if (entity.status === MediaStatus.AVAILABLE) {
|
||||
if (entity.mediaType === MediaType.MOVIE) {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const relatedRequests = await requestRepository.find({
|
||||
where: { media: entity },
|
||||
});
|
||||
|
||||
if (relatedRequests.length > 0) {
|
||||
const tmdb = new TheMovieDb();
|
||||
const movie = await tmdb.getMovie({ movieId: entity.tmdbId });
|
||||
|
||||
relatedRequests.forEach((request) => {
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
notifyUser: request.requestedBy,
|
||||
subject: movie.title,
|
||||
message: movie.overview,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyAvailableSeries(entity: Media, dbEntity: Media) {
|
||||
const newAvailableSeasons = entity.seasons
|
||||
.filter((season) => season.status === MediaStatus.AVAILABLE)
|
||||
.map((season) => season.seasonNumber);
|
||||
const oldAvailableSeasons = dbEntity.seasons
|
||||
.filter((season) => season.status === MediaStatus.AVAILABLE)
|
||||
.map((season) => season.seasonNumber);
|
||||
|
||||
const changedSeasons = newAvailableSeasons.filter(
|
||||
(seasonNumber) => !oldAvailableSeasons.includes(seasonNumber)
|
||||
);
|
||||
|
||||
if (changedSeasons.length > 0) {
|
||||
const tmdb = new TheMovieDb();
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const processedSeasons: number[] = [];
|
||||
|
||||
for (const changedSeasonNumber of changedSeasons) {
|
||||
const requests = await requestRepository.find({
|
||||
where: { media: entity },
|
||||
});
|
||||
const request = requests.find(
|
||||
(request) =>
|
||||
// Check if the season is complete AND it contains the current season that was just marked available
|
||||
request.seasons.every((season) =>
|
||||
newAvailableSeasons.includes(season.seasonNumber)
|
||||
) &&
|
||||
request.seasons.some(
|
||||
(season) => season.seasonNumber === changedSeasonNumber
|
||||
)
|
||||
);
|
||||
|
||||
if (request && !processedSeasons.includes(changedSeasonNumber)) {
|
||||
processedSeasons.push(
|
||||
...request.seasons.map((season) => season.seasonNumber)
|
||||
);
|
||||
const tv = await tmdb.getTvShow({ tvId: entity.tmdbId });
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
subject: tv.name,
|
||||
message: tv.overview,
|
||||
notifyUser: request.requestedBy,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||
extra: [
|
||||
{
|
||||
name: 'Seasons',
|
||||
value: request.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public beforeUpdate(event: UpdateEvent<Media>): void {
|
||||
if (
|
||||
event.entity.mediaType === MediaType.MOVIE &&
|
||||
event.entity.status === MediaStatus.AVAILABLE
|
||||
) {
|
||||
this.notifyAvailableMovie(event.entity);
|
||||
}
|
||||
|
||||
if (
|
||||
event.entity.mediaType === MediaType.TV &&
|
||||
(event.entity.status === MediaStatus.AVAILABLE ||
|
||||
event.entity.status === MediaStatus.PARTIALLY_AVAILABLE)
|
||||
) {
|
||||
this.notifyAvailableSeries(event.entity, event.databaseEntity);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
|
||||
padding-bottom: 25px;\
|
||||
text-align: center;\
|
||||
')
|
||||
a(href='https://example.com' style='\
|
||||
a(href=actionUrl style='\
|
||||
text-shadow: 0 1px 0 #ffffff;\
|
||||
font-weight: 700;\
|
||||
font-size: 16px;\
|
||||
|
||||
3
server/types/plex-api.d.ts
vendored
3
server/types/plex-api.d.ts
vendored
@@ -2,8 +2,9 @@ declare module 'plex-api' {
|
||||
export default class PlexAPI {
|
||||
constructor(intiialOptions: {
|
||||
hostname: string;
|
||||
post: number;
|
||||
port: number;
|
||||
token?: string;
|
||||
https?: boolean;
|
||||
authenticator: {
|
||||
authenticate: (
|
||||
_plexApi: PlexAPI,
|
||||
|
||||
12
server/utils/appVersion.ts
Normal file
12
server/utils/appVersion.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const getAppVersion = (): string => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { version } = require('../../package.json');
|
||||
|
||||
let finalVersion = version;
|
||||
|
||||
if (version === '0.1.0') {
|
||||
finalVersion = `develop-${process.env.COMMIT_TAG ?? 'local'}`;
|
||||
}
|
||||
|
||||
return finalVersion;
|
||||
};
|
||||
3
src/assets/services/imdb.svg
Normal file
3
src/assets/services/imdb.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid meet" viewBox="0 0 575 289.83"><defs><path d="M575 24.91C573.44 12.15 563.97 1.98 551.91 0C499.05 0 76.18 0 23.32 0C10.11 2.17 0 14.16 0 28.61C0 51.84 0 237.64 0 260.86C0 276.86 12.37 289.83 27.64 289.83C79.63 289.83 495.6 289.83 547.59 289.83C561.65 289.83 573.26 278.82 575 264.57C575 216.64 575 48.87 575 24.91Z" id="d1pwhf9wy2"></path><path d="M69.35 58.24L114.98 58.24L114.98 233.89L69.35 233.89L69.35 58.24Z" id="g5jjnq26yS"></path><path d="M201.2 139.15C197.28 112.38 195.1 97.5 194.67 94.53C192.76 80.2 190.94 67.73 189.2 57.09C185.25 57.09 165.54 57.09 130.04 57.09L130.04 232.74L170.01 232.74L170.15 116.76L186.97 232.74L215.44 232.74L231.39 114.18L231.54 232.74L271.38 232.74L271.38 57.09L211.77 57.09L201.2 139.15Z" id="i3Prh1JpXt"></path><path d="M346.71 93.63C347.21 95.87 347.47 100.95 347.47 108.89C347.47 115.7 347.47 170.18 347.47 176.99C347.47 188.68 346.71 195.84 345.2 198.48C343.68 201.12 339.64 202.43 333.09 202.43C333.09 190.9 333.09 98.66 333.09 87.13C338.06 87.13 341.45 87.66 343.25 88.7C345.05 89.75 346.21 91.39 346.71 93.63ZM367.32 230.95C372.75 229.76 377.31 227.66 381.01 224.67C384.7 221.67 387.29 217.52 388.77 212.21C390.26 206.91 391.14 196.38 391.14 180.63C391.14 174.47 391.14 125.12 391.14 118.95C391.14 102.33 390.49 91.19 389.48 85.53C388.46 79.86 385.93 74.71 381.88 70.09C377.82 65.47 371.9 62.15 364.12 60.13C356.33 58.11 343.63 57.09 321.54 57.09C319.27 57.09 307.93 57.09 287.5 57.09L287.5 232.74L342.78 232.74C355.52 232.34 363.7 231.75 367.32 230.95Z" id="a4ov9rRGQm"></path><path d="M464.76 204.7C463.92 206.93 460.24 208.06 457.46 208.06C454.74 208.06 452.93 206.98 452.01 204.81C451.09 202.65 450.64 197.72 450.64 190C450.64 185.36 450.64 148.22 450.64 143.58C450.64 135.58 451.04 130.59 451.85 128.6C452.65 126.63 454.41 125.63 457.13 125.63C459.91 125.63 463.64 126.76 464.6 129.03C465.55 131.3 466.03 136.15 466.03 143.58C466.03 146.58 466.03 161.58 466.03 188.59C465.74 197.84 465.32 203.21 464.76 204.7ZM406.68 231.21L447.76 231.21C449.47 224.5 450.41 220.77 450.6 220.02C454.32 224.52 458.41 227.9 462.9 230.14C467.37 232.39 474.06 233.51 479.24 233.51C486.45 233.51 492.67 231.62 497.92 227.83C503.16 224.05 506.5 219.57 507.92 214.42C509.34 209.26 510.05 201.42 510.05 190.88C510.05 185.95 510.05 146.53 510.05 141.6C510.05 131 509.81 124.08 509.34 120.83C508.87 117.58 507.47 114.27 505.14 110.88C502.81 107.49 499.42 104.86 494.98 102.98C490.54 101.1 485.3 100.16 479.26 100.16C474.01 100.16 467.29 101.21 462.81 103.28C458.34 105.35 454.28 108.49 450.64 112.7C450.64 108.89 450.64 89.85 450.64 55.56L406.68 55.56L406.68 231.21Z" id="fk968BpsX"></path></defs><g><g><g><use xlink:href="#d1pwhf9wy2" opacity="1" fill="#f6c700" fill-opacity="1"></use><g><use xlink:href="#d1pwhf9wy2" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#g5jjnq26yS" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#g5jjnq26yS" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#fk968BpsX" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#fk968BpsX" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
1
src/assets/services/rt.svg
Normal file
1
src/assets/services/rt.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.4 KiB |
1
src/assets/services/tmdb.svg
Normal file
1
src/assets/services/tmdb.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 185.04 133.4"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="66.7" x2="185.04" y2="66.7" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 4</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M51.06,66.7h0A17.67,17.67,0,0,1,68.73,49h-.1A17.67,17.67,0,0,1,86.3,66.7h0A17.67,17.67,0,0,1,68.63,84.37h.1A17.67,17.67,0,0,1,51.06,66.7Zm82.67-31.33h32.9A17.67,17.67,0,0,0,184.3,17.7h0A17.67,17.67,0,0,0,166.63,0h-32.9A17.67,17.67,0,0,0,116.06,17.7h0A17.67,17.67,0,0,0,133.73,35.37Zm-113,98h63.9A17.67,17.67,0,0,0,102.3,115.7h0A17.67,17.67,0,0,0,84.63,98H20.73A17.67,17.67,0,0,0,3.06,115.7h0A17.67,17.67,0,0,0,20.73,133.37Zm83.92-49h6.25L125.5,49h-8.35l-8.9,23.2h-.1L99.4,49H90.5Zm32.45,0h7.8V49h-7.8Zm22.2,0h24.95V77.2H167.1V70h15.35V62.8H167.1V56.2h16.25V49h-24ZM10.1,35.4h7.8V6.9H28V0H0V6.9H10.1ZM39,35.4h7.8V20.1H61.9V35.4h7.8V0H61.9V13.2H46.75V0H39Zm41.25,0h25V28.2H88V21h15.35V13.8H88V7.2h16.25V0h-24Zm-79,49H9V57.25h.1l9,27.15H24l9.3-27.15h.1V84.4h7.8V49H29.45l-8.2,23.1h-.1L13,49H1.2Zm112.09,49H126a24.59,24.59,0,0,0,7.56-1.15,19.52,19.52,0,0,0,6.35-3.37,16.37,16.37,0,0,0,4.37-5.5A16.91,16.91,0,0,0,146,115.8a18.5,18.5,0,0,0-1.68-8.25,15.1,15.1,0,0,0-4.52-5.53A18.55,18.55,0,0,0,133.07,99,33.54,33.54,0,0,0,125,98H113.29Zm7.81-28.2h4.6a17.43,17.43,0,0,1,4.67.62,11.68,11.68,0,0,1,3.88,1.88,9,9,0,0,1,2.62,3.18,9.87,9.87,0,0,1,1,4.52,11.92,11.92,0,0,1-1,5.08,8.69,8.69,0,0,1-2.67,3.34,10.87,10.87,0,0,1-4,1.83,21.57,21.57,0,0,1-5,.55H121.1Zm36.14,28.2h14.5a23.11,23.11,0,0,0,4.73-.5,13.38,13.38,0,0,0,4.27-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68,9.16,9.16,0,0,0-.55-3.2,7.79,7.79,0,0,0-1.57-2.62,8.38,8.38,0,0,0-2.45-1.85,10,10,0,0,0-3.18-1v-.1a9.28,9.28,0,0,0,4.43-2.82,7.42,7.42,0,0,0,1.67-5,8.34,8.34,0,0,0-1.15-4.65,7.88,7.88,0,0,0-3-2.73,12.9,12.9,0,0,0-4.17-1.3,34.42,34.42,0,0,0-4.63-.32h-13.2Zm7.8-28.8h5.3a10.79,10.79,0,0,1,1.85.17,5.77,5.77,0,0,1,1.7.58,3.33,3.33,0,0,1,1.23,1.13,3.22,3.22,0,0,1,.47,1.82,3.63,3.63,0,0,1-.42,1.8,3.34,3.34,0,0,1-1.13,1.2,4.78,4.78,0,0,1-1.57.65,8.16,8.16,0,0,1-1.78.2H165Zm0,14.15h5.9a15.12,15.12,0,0,1,2.05.15,7.83,7.83,0,0,1,2,.55,4,4,0,0,1,1.58,1.17,3.13,3.13,0,0,1,.62,2,3.71,3.71,0,0,1-.47,1.95,4,4,0,0,1-1.23,1.3,4.78,4.78,0,0,1-1.67.7,8.91,8.91,0,0,1-1.83.2h-7Z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
interface HeaderProps {
|
||||
extraMargin?: number;
|
||||
subtext?: string;
|
||||
subtext?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({
|
||||
|
||||
40
src/components/Common/List/index.tsx
Normal file
40
src/components/Common/List/index.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { withProperties } from '../../../utils/typeHelpers';
|
||||
|
||||
interface ListItemProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
const ListItem: React.FC<ListItemProps> = ({ title, children }) => {
|
||||
return (
|
||||
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt className="text-sm font-medium text-gray-200">{title}</dt>
|
||||
<dd className="mt-1 flex text-sm text-gray-400 sm:mt-0 sm:col-span-2">
|
||||
<span className="flex-grow">{children}</span>
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ListProps {
|
||||
title: string;
|
||||
subTitle?: string;
|
||||
}
|
||||
|
||||
const List: React.FC<ListProps> = ({ title, subTitle, children }) => {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-100">{title}</h3>
|
||||
{subTitle && (
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-300">{subTitle}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-5 border-t border-gray-800">
|
||||
<dl className="divide-y divide-gray-800">{children}</dl>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default withProperties(List, { Item: ListItem });
|
||||
@@ -69,6 +69,7 @@ const ListView: React.FC<ListViewProps> = ({
|
||||
case 'person':
|
||||
titleCard = (
|
||||
<PersonCard
|
||||
personId={title.id}
|
||||
name={title.name}
|
||||
profilePath={title.profilePath}
|
||||
canExpand
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React, { MouseEvent, ReactNode, useRef } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import Button, { ButtonType } from '../Button';
|
||||
import { useTransition, animated } from 'react-spring';
|
||||
import { useLockBodyScroll } from '../../../hooks/useLockBodyScroll';
|
||||
import LoadingSpinner from '../LoadingSpinner';
|
||||
import useClickOutside from '../../../hooks/useClickOutside';
|
||||
import { useIntl } from 'react-intl';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import Transition from '../../Transition';
|
||||
|
||||
interface ModalProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
interface ModalProps {
|
||||
title?: string;
|
||||
onCancel?: (e?: MouseEvent<HTMLElement>) => void;
|
||||
onOk?: (e?: MouseEvent<HTMLButtonElement>) => void;
|
||||
@@ -53,7 +53,6 @@ const Modal: React.FC<ModalProps> = ({
|
||||
tertiaryDisabled = false,
|
||||
tertiaryText,
|
||||
onTertiary,
|
||||
...props
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
@@ -63,134 +62,123 @@ const Modal: React.FC<ModalProps> = ({
|
||||
: undefined;
|
||||
});
|
||||
useLockBodyScroll(true, disableScrollLock);
|
||||
const containerTransitions = useTransition(!loading, null, {
|
||||
from: { opacity: 0, transform: 'scale(0.5)' },
|
||||
enter: { opacity: 1, transform: 'scale(1)' },
|
||||
leave: { opacity: 0, transform: 'scale(0.5)' },
|
||||
config: { tension: 500, velocity: 40, friction: 60 },
|
||||
});
|
||||
const loadingTransitions = useTransition(loading, null, {
|
||||
from: { opacity: 0, transform: 'scale(0.5)' },
|
||||
enter: { opacity: 1, transform: 'scale(1)' },
|
||||
leave: { opacity: 0, transform: 'scale(0.5)' },
|
||||
config: { tension: 500, velocity: 40, friction: 60 },
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{ReactDOM.createPortal(
|
||||
<animated.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"
|
||||
style={props.style}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
typeof onCancel === 'function' && backgroundClickable
|
||||
? onCancel()
|
||||
: undefined;
|
||||
}
|
||||
}}
|
||||
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"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
typeof onCancel === 'function' && backgroundClickable
|
||||
? onCancel()
|
||||
: undefined;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Transition
|
||||
enter="transition opacity-0 duration-300 transform scale-75"
|
||||
enterFrom="opacity-0 scale-75"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={loading}
|
||||
>
|
||||
<div style={{ position: 'absolute' }}>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition
|
||||
enter="transition opacity-0 duration-300 transform scale-75"
|
||||
enterFrom="opacity-0 scale-75"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
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 sm:p-6 max-h-full"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline"
|
||||
ref={modalRef}
|
||||
>
|
||||
{loadingTransitions.map(
|
||||
({ props, item, key }) =>
|
||||
item && (
|
||||
<animated.div
|
||||
style={{ ...props, position: 'absolute' }}
|
||||
key={key}
|
||||
<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">
|
||||
{iconSvg}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`mt-3 text-center sm:mt-0 sm:text-left ${
|
||||
iconSvg ? 'sm:ml-4' : 'mb-6'
|
||||
}`}
|
||||
>
|
||||
{title && (
|
||||
<h3
|
||||
className="text-lg leading-6 font-medium text-white"
|
||||
id="modal-headline"
|
||||
>
|
||||
<LoadingSpinner />
|
||||
</animated.div>
|
||||
)
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{children && (
|
||||
<div className="mt-4 text-sm leading-5 text-gray-300">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{containerTransitions.map(
|
||||
({ props, item, key }) =>
|
||||
item && (
|
||||
<animated.div
|
||||
style={props}
|
||||
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 sm:p-6 max-h-full"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline"
|
||||
key={key}
|
||||
ref={modalRef}
|
||||
{(onCancel || onOk || onSecondary || onTertiary) && (
|
||||
<div className="mt-5 sm:mt-4 flex justify-center sm:justify-start flex-row-reverse">
|
||||
{typeof onOk === 'function' && (
|
||||
<Button
|
||||
buttonType={okButtonType}
|
||||
onClick={onOk}
|
||||
className="ml-3"
|
||||
disabled={okDisabled}
|
||||
>
|
||||
<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">
|
||||
{iconSvg}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`mt-3 text-center sm:mt-0 sm:text-left ${
|
||||
iconSvg ? 'sm:ml-4' : 'mb-6'
|
||||
}`}
|
||||
>
|
||||
{title && (
|
||||
<h3
|
||||
className="text-lg leading-6 font-medium text-white"
|
||||
id="modal-headline"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{children && (
|
||||
<div className="mt-4 text-sm leading-5 text-gray-300">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{(onCancel || onOk || onSecondary || onTertiary) && (
|
||||
<div className="mt-5 sm:mt-4 flex justify-center sm:justify-start flex-row-reverse">
|
||||
{typeof onOk === 'function' && (
|
||||
<Button
|
||||
buttonType={okButtonType}
|
||||
onClick={onOk}
|
||||
className="ml-3"
|
||||
disabled={okDisabled}
|
||||
>
|
||||
{okText ? okText : 'Ok'}
|
||||
</Button>
|
||||
)}
|
||||
{typeof onSecondary === 'function' && secondaryText && (
|
||||
<Button
|
||||
buttonType={secondaryButtonType}
|
||||
onClick={onSecondary}
|
||||
className="ml-3"
|
||||
disabled={secondaryDisabled}
|
||||
>
|
||||
{secondaryText}
|
||||
</Button>
|
||||
)}
|
||||
{typeof onTertiary === 'function' && tertiaryText && (
|
||||
<Button
|
||||
buttonType={tertiaryButtonType}
|
||||
onClick={onTertiary}
|
||||
className="ml-3"
|
||||
disabled={tertiaryDisabled}
|
||||
>
|
||||
{tertiaryText}
|
||||
</Button>
|
||||
)}
|
||||
{typeof onCancel === 'function' && (
|
||||
<Button
|
||||
buttonType={cancelButtonType}
|
||||
onClick={onCancel}
|
||||
className="ml-3 sm:ml-0 sm:px-4"
|
||||
>
|
||||
{cancelText
|
||||
? cancelText
|
||||
: intl.formatMessage(globalMessages.cancel)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</animated.div>
|
||||
)
|
||||
{okText ? okText : 'Ok'}
|
||||
</Button>
|
||||
)}
|
||||
{typeof onSecondary === 'function' && secondaryText && (
|
||||
<Button
|
||||
buttonType={secondaryButtonType}
|
||||
onClick={onSecondary}
|
||||
className="ml-3"
|
||||
disabled={secondaryDisabled}
|
||||
>
|
||||
{secondaryText}
|
||||
</Button>
|
||||
)}
|
||||
{typeof onTertiary === 'function' && tertiaryText && (
|
||||
<Button
|
||||
buttonType={tertiaryButtonType}
|
||||
onClick={onTertiary}
|
||||
className="ml-3"
|
||||
disabled={tertiaryDisabled}
|
||||
>
|
||||
{tertiaryText}
|
||||
</Button>
|
||||
)}
|
||||
{typeof onCancel === 'function' && (
|
||||
<Button
|
||||
buttonType={cancelButtonType}
|
||||
onClick={onCancel}
|
||||
className="ml-3 sm:ml-0 sm:px-4"
|
||||
>
|
||||
{cancelText
|
||||
? cancelText
|
||||
: intl.formatMessage(globalMessages.cancel)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</animated.div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
70
src/components/Discover/Holiday/index.tsx
Normal file
70
src/components/Discover/Holiday/index.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { useSWRInfinite } from 'swr';
|
||||
import type { MovieResult } from '../../../../server/models/Search';
|
||||
import ListView from '../../Common/ListView';
|
||||
import { LanguageContext } from '../../../context/LanguageContext';
|
||||
import Header from '../../Common/Header';
|
||||
|
||||
interface SearchResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: MovieResult[];
|
||||
}
|
||||
|
||||
const Holiday: React.FC = () => {
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/v1/discover/keyword/207317/movies?page=${
|
||||
pageIndex + 1
|
||||
}&language=${locale}`;
|
||||
},
|
||||
{
|
||||
initialSize: 3,
|
||||
}
|
||||
);
|
||||
|
||||
const isLoadingInitialData = !data && !error;
|
||||
const isLoadingMore =
|
||||
isLoadingInitialData ||
|
||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
||||
|
||||
const fetchMore = () => {
|
||||
setSize(size + 1);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <div>{error}</div>;
|
||||
}
|
||||
|
||||
const titles = data?.reduce(
|
||||
(a, v) => [...a, ...v.results],
|
||||
[] as MovieResult[]
|
||||
);
|
||||
|
||||
const isEmpty = !isLoadingInitialData && titles?.length === 0;
|
||||
const isReachingEnd =
|
||||
isEmpty || (data && data[data.length - 1]?.results.length < 20);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>Happy Holidays!</Header>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Holiday;
|
||||
@@ -57,9 +57,18 @@ const Discover: React.FC = () => {
|
||||
`/api/v1/discover/tv?language=${locale}`
|
||||
);
|
||||
|
||||
const { data: movieUpcomingData, error: movieUpcomingError } = useSWR<
|
||||
MovieDiscoverResult
|
||||
>(`/api/v1/discover/movies/upcoming?language=${locale}`);
|
||||
const {
|
||||
data: movieUpcomingData,
|
||||
error: movieUpcomingError,
|
||||
} = useSWR<MovieDiscoverResult>(
|
||||
`/api/v1/discover/movies/upcoming?language=${locale}`
|
||||
);
|
||||
const {
|
||||
data: holUpcomingData,
|
||||
error: holUpcomingError,
|
||||
} = useSWR<MovieDiscoverResult>(
|
||||
`/api/v1/discover/keyword/207317/movies?language=${locale}`
|
||||
);
|
||||
|
||||
const { data: trendingData, error: trendingError } = useSWR<MixedResult>(
|
||||
`/api/v1/discover/trending?language=${locale}`
|
||||
@@ -69,9 +78,12 @@ const Discover: React.FC = () => {
|
||||
'/api/v1/media?filter=available&take=20&sort=modified'
|
||||
);
|
||||
|
||||
const { data: requests, error: requestError } = useSWR<
|
||||
RequestResultsResponse
|
||||
>('/api/v1/request?filter=unavailable&take=10&sort=modified&skip=0');
|
||||
const {
|
||||
data: requests,
|
||||
error: requestError,
|
||||
} = useSWR<RequestResultsResponse>(
|
||||
'/api/v1/request?filter=unavailable&take=10&sort=modified&skip=0'
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -134,6 +146,57 @@ const Discover: React.FC = () => {
|
||||
placeholder={<RequestCard.Placeholder />}
|
||||
emptyMessage={intl.formatMessage(messages.nopending)}
|
||||
/>
|
||||
{/* Special Temporary Slider */}
|
||||
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link href="/discover/holiday">
|
||||
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
|
||||
<span>
|
||||
<span role="img" aria-label="Christmas Tree" className="mr-2">
|
||||
🎄
|
||||
</span>
|
||||
Happy Holidays!
|
||||
<span role="img" aria-label="Christmas Tree" className="ml-2">
|
||||
🎄
|
||||
</span>
|
||||
</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="holiday"
|
||||
isLoading={!holUpcomingData && !holUpcomingError}
|
||||
isEmpty={false}
|
||||
items={holUpcomingData?.results.map((title) => (
|
||||
<TitleCard
|
||||
key={`holiday-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}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
{/* End Special Temporary Slider */}
|
||||
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link href="/discover/movies/upcoming">
|
||||
@@ -236,7 +299,11 @@ const Discover: React.FC = () => {
|
||||
);
|
||||
case 'person':
|
||||
return (
|
||||
<PersonCard name={title.name} profilePath={title.profilePath} />
|
||||
<PersonCard
|
||||
personId={title.id}
|
||||
name={title.name}
|
||||
profilePath={title.profilePath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
||||
55
src/components/ExternalLinkBlock/index.tsx
Normal file
55
src/components/ExternalLinkBlock/index.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import TmdbLogo from '../../assets/services/tmdb.svg';
|
||||
import ImdbLogo from '../../assets/services/imdb.svg';
|
||||
import RTLogo from '../../assets/services/rt.svg';
|
||||
|
||||
interface ExternalLinkBlockProps {
|
||||
mediaType: 'movie' | 'tv';
|
||||
imdbId?: string;
|
||||
tmdbId?: number;
|
||||
rtUrl?: string;
|
||||
}
|
||||
|
||||
const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
|
||||
imdbId,
|
||||
tmdbId,
|
||||
rtUrl,
|
||||
mediaType,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex justify-end items-center">
|
||||
{tmdbId && (
|
||||
<a
|
||||
href={`https://www.themoviedb.org/${mediaType}/${tmdbId}`}
|
||||
className="w-8 mx-2 opacity-50 hover:opacity-100 transition duration-300"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<TmdbLogo />
|
||||
</a>
|
||||
)}
|
||||
{imdbId && (
|
||||
<a
|
||||
href={`https://www.imdb.com/title/${imdbId}`}
|
||||
className="w-8 mx-2 opacity-50 hover:opacity-100 transition duration-300"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ImdbLogo />
|
||||
</a>
|
||||
)}
|
||||
{rtUrl && (
|
||||
<a
|
||||
href={`${rtUrl}`}
|
||||
className="w-14 mx-2 opacity-50 hover:opacity-100 transition duration-300"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<RTLogo />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExternalLinkBlock;
|
||||
@@ -29,6 +29,26 @@ const availableLanguages: AvailableLanguageObject = {
|
||||
code: 'fr',
|
||||
display: 'Français',
|
||||
},
|
||||
'nb-NO': {
|
||||
code: 'nb-NO',
|
||||
display: 'Norwegian Bokmål',
|
||||
},
|
||||
de: {
|
||||
code: 'de',
|
||||
display: 'German',
|
||||
},
|
||||
ru: {
|
||||
code: 'ru',
|
||||
display: 'Russian',
|
||||
},
|
||||
nl: {
|
||||
code: 'nl',
|
||||
display: 'Nederlands',
|
||||
},
|
||||
es: {
|
||||
code: 'es',
|
||||
display: 'Spanish',
|
||||
},
|
||||
};
|
||||
|
||||
const LanguagePicker: React.FC = () => {
|
||||
@@ -38,7 +58,7 @@ const LanguagePicker: React.FC = () => {
|
||||
useClickOutside(dropdownRef, () => setDropdownOpen(false));
|
||||
|
||||
return (
|
||||
<div className="ml-3 relative">
|
||||
<div className="relative">
|
||||
<div>
|
||||
<button
|
||||
className="p-1 text-gray-400 rounded-full hover:bg-gray-500 hover:text-white focus:outline-none focus:ring focus:text-white"
|
||||
@@ -61,10 +81,10 @@ const LanguagePicker: React.FC = () => {
|
||||
</div>
|
||||
<Transition
|
||||
show={isDropdownOpen}
|
||||
enter="transition ease-out duration-100"
|
||||
enter="transition ease-out duration-100 opacity-0"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leave="transition ease-in duration-75 opacity-100"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
|
||||
@@ -27,7 +27,7 @@ const SearchInput: React.FC = () => {
|
||||
</div>
|
||||
<input
|
||||
id="search_field"
|
||||
className="block w-full h-full pl-8 pr-3 py-2 rounded-md border-transparent focus:border-transparent bg-gray-600 text-white placeholder-gray-300 focus:outline-none focus:ring-0 focus:placeholder-gray-400 sm:text-base"
|
||||
className="block w-full h-full pl-8 pr-1 py-2 rounded-md border-transparent focus:border-transparent bg-gray-600 text-white placeholder-gray-300 focus:outline-none focus:ring-0 focus:placeholder-gray-400 sm:text-base"
|
||||
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
||||
type="search"
|
||||
value={searchValue}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import React, { ReactNode, useRef } from 'react';
|
||||
import Transition from '../../Transition';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
import { useUser, Permission } from '../../../hooks/useUser';
|
||||
import useClickOutside from '../../../hooks/useClickOutside';
|
||||
|
||||
const messages = defineMessages({
|
||||
dashboard: 'Discover',
|
||||
@@ -116,8 +117,10 @@ const SidebarLinks: SidebarLinkProps[] = [
|
||||
];
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
|
||||
const navRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
const { hasPermission } = useUser();
|
||||
useClickOutside(navRef, () => setClosed());
|
||||
return (
|
||||
<>
|
||||
<div className="md:hidden">
|
||||
@@ -166,7 +169,10 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 h-0 pt-5 pb-4 overflow-y-auto">
|
||||
<div
|
||||
ref={navRef}
|
||||
className="flex-1 h-0 pt-5 pb-4 overflow-y-auto"
|
||||
>
|
||||
<div className="flex-shrink-0 flex items-center px-4">
|
||||
<span className="text-xl text-gray-50">
|
||||
<Link href="/">
|
||||
|
||||
@@ -2,10 +2,10 @@ import React, { useState } from 'react';
|
||||
import SearchInput from './SearchInput';
|
||||
import UserDropdown from './UserDropdown';
|
||||
import Sidebar from './Sidebar';
|
||||
import Notifications from './Notifications';
|
||||
import LanguagePicker from './LanguagePicker';
|
||||
import { useRouter } from 'next/router';
|
||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
import { Permission, useUser } from '../../hooks/useUser';
|
||||
|
||||
const messages = defineMessages({
|
||||
alphawarning:
|
||||
@@ -14,6 +14,7 @@ const messages = defineMessages({
|
||||
|
||||
const Layout: React.FC = ({ children }) => {
|
||||
const [isSidebarOpen, setSidebarOpen] = useState(false);
|
||||
const { hasPermission } = useUser();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
@@ -43,9 +44,8 @@ const Layout: React.FC = ({ children }) => {
|
||||
</button>
|
||||
<div className="flex-1 px-4 flex justify-between">
|
||||
<SearchInput />
|
||||
<div className="ml-4 flex items-center md:ml-6">
|
||||
<div className="flex items-center md:ml-6">
|
||||
<LanguagePicker />
|
||||
<Notifications />
|
||||
<UserDropdown />
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,7 +57,7 @@ const Layout: React.FC = ({ children }) => {
|
||||
>
|
||||
<div className="pt-2 pb-6 md:py-6">
|
||||
<div className="max-w-8xl mx-auto px-4 sm:px-6 md:px-8">
|
||||
{router.pathname === '/' && (
|
||||
{router.pathname === '/' && hasPermission(Permission.ADMIN) && (
|
||||
<div className="rounded-md bg-indigo-700 p-4 mt-2">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
@@ -85,7 +85,7 @@ const Layout: React.FC = ({ children }) => {
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Github →
|
||||
GitHub →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -5,25 +5,35 @@ import axios from 'axios';
|
||||
import { useRouter } from 'next/dist/client/router';
|
||||
import ImageFader from '../Common/ImageFader';
|
||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
import Transition from '../Transition';
|
||||
|
||||
const messages = defineMessages({
|
||||
signinplex: 'Sign in to continue',
|
||||
});
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const [error, setError] = useState('');
|
||||
const [isProcessing, setProcessing] = useState(false);
|
||||
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
|
||||
const { user, revalidate } = useUser();
|
||||
const router = useRouter();
|
||||
|
||||
// Effect that is triggered when the `authToken` comes back from the Plex OAuth
|
||||
// We take the token and attempt to login. If we get a success message, we will
|
||||
// ask swr to revalidate the user which _shouid_ come back with a valid user.
|
||||
// ask swr to revalidate the user which _should_ come back with a valid user.
|
||||
useEffect(() => {
|
||||
const login = async () => {
|
||||
const response = await axios.post('/api/v1/auth/login', { authToken });
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await axios.post('/api/v1/auth/login', { authToken });
|
||||
|
||||
if (response.data?.email) {
|
||||
revalidate();
|
||||
if (response.data?.email) {
|
||||
revalidate();
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e.response.data.message);
|
||||
setAuthToken(undefined);
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
if (authToken) {
|
||||
@@ -64,7 +74,40 @@ const Login: React.FC = () => {
|
||||
className="bg-gray-800 bg-opacity-50 py-8 px-4 shadow sm:rounded-lg sm:px-10"
|
||||
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="rounded-md bg-red-600 p-4 mb-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="h-5 w-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>
|
||||
<PlexLoginButton
|
||||
isProcessing={isProcessing}
|
||||
onAuthToken={(authToken) => setAuthToken(authToken)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
66
src/components/MovieDetails/MovieCast/index.tsx
Normal file
66
src/components/MovieDetails/MovieCast/index.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useContext } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import { MovieDetails } from '../../../../server/models/Movie';
|
||||
import { LanguageContext } from '../../../context/LanguageContext';
|
||||
import Error from '../../../pages/_error';
|
||||
import Header from '../../Common/Header';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import PersonCard from '../../PersonCard';
|
||||
|
||||
const messages = defineMessages({
|
||||
fullcast: 'Full Cast',
|
||||
});
|
||||
|
||||
const MovieCast: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error } = useSWR<MovieDetails>(
|
||||
`/api/v1/movie/${router.query.movieId}?language=${locale}`
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Error statusCode={404} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
subtext={
|
||||
<Link href={`/movie/${data.id}`}>
|
||||
<a className="hover:underline">{data.title}</a>
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{intl.formatMessage(messages.fullcast)}
|
||||
</Header>
|
||||
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
|
||||
{data?.credits.cast.map((person) => {
|
||||
return (
|
||||
<li
|
||||
key={person.id}
|
||||
className="col-span-1 flex flex-col text-center items-center"
|
||||
>
|
||||
<PersonCard
|
||||
name={person.name}
|
||||
personId={person.id}
|
||||
subName={person.character}
|
||||
profilePath={person.profilePath}
|
||||
canExpand
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MovieCast;
|
||||
@@ -37,6 +37,7 @@ import type { RTRating } from '../../../server/api/rottentomatoes';
|
||||
import Error from '../../pages/_error';
|
||||
import Head from 'next/head';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import ExternalLinkBlock from '../ExternalLinkBlock';
|
||||
|
||||
const messages = defineMessages({
|
||||
releasedate: 'Release Date',
|
||||
@@ -65,6 +66,7 @@ const messages = defineMessages({
|
||||
'This will remove all media data including all requests for this item. This action is irreversible. If this item exists in your Plex library, the media information will be recreated next sync.',
|
||||
approve: 'Approve',
|
||||
decline: 'Decline',
|
||||
studio: 'Studio',
|
||||
});
|
||||
|
||||
interface MovieDetailsProps {
|
||||
@@ -193,14 +195,14 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
)}
|
||||
</SlideOver>
|
||||
<div className="flex flex-col items-center md:flex-row md:items-end pt-4">
|
||||
<div className="mr-4 flex-shrink-0">
|
||||
<div className="md:mr-4 flex-shrink-0">
|
||||
<img
|
||||
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
|
||||
alt=""
|
||||
className="rounded md:rounded-lg shadow md:shadow-2xl w-32 md:w-52"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-white flex flex-col mr-4 mt-4 md:mt-0 text-center md:text-left">
|
||||
<div className="text-white flex flex-col md:mr-4 mt-4 md:mt-0 text-center md:text-left">
|
||||
<div className="mb-2">
|
||||
{data.mediaInfo?.status === MediaStatus.AVAILABLE && (
|
||||
<Badge badgeType="success">
|
||||
@@ -352,7 +354,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
{hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<Button
|
||||
buttonType="default"
|
||||
className="ml-2"
|
||||
className="ml-2 first:ml-0"
|
||||
onClick={() => setShowManager(true)}
|
||||
>
|
||||
<svg
|
||||
@@ -395,34 +397,36 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
<div className="bg-gray-900 rounded-lg shadow border border-gray-800">
|
||||
{(data.voteCount > 0 || ratingData) && (
|
||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0 items-center justify-center">
|
||||
{ratingData?.criticsRating && (
|
||||
<>
|
||||
<span className="text-sm">
|
||||
{ratingData.criticsRating === 'Rotten' ? (
|
||||
<RTRotten className="w-6 mr-1" />
|
||||
) : (
|
||||
<RTFresh className="w-6 mr-1" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-gray-400 text-sm mr-4 last:mr-0">
|
||||
{ratingData.criticsScore}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{ratingData?.audienceRating && (
|
||||
<>
|
||||
<span className="text-sm">
|
||||
{ratingData.audienceRating === 'Spilled' ? (
|
||||
<RTAudRotten className="w-6 mr-1" />
|
||||
) : (
|
||||
<RTAudFresh className="w-6 mr-1" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-gray-400 text-sm mr-4 last:mr-0">
|
||||
{ratingData.audienceScore}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{ratingData?.criticsRating &&
|
||||
(ratingData?.criticsScore ?? 0) > 0 && (
|
||||
<>
|
||||
<span className="text-sm">
|
||||
{ratingData.criticsRating === 'Rotten' ? (
|
||||
<RTRotten className="w-6 mr-1" />
|
||||
) : (
|
||||
<RTFresh className="w-6 mr-1" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-gray-400 text-sm mr-4 last:mr-0">
|
||||
{ratingData.criticsScore}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{ratingData?.audienceRating &&
|
||||
(ratingData?.audienceScore ?? 0) > 0 && (
|
||||
<>
|
||||
<span className="text-sm">
|
||||
{ratingData.audienceRating === 'Spilled' ? (
|
||||
<RTAudRotten className="w-6 mr-1" />
|
||||
) : (
|
||||
<RTAudFresh className="w-6 mr-1" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-gray-400 text-sm mr-4 last:mr-0">
|
||||
{ratingData.audienceScore}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{data.voteCount > 0 && (
|
||||
<>
|
||||
<span className="text-sm">
|
||||
@@ -484,14 +488,40 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
|
||||
<span className="text-sm">
|
||||
<FormattedMessage {...messages.originallanguage} />
|
||||
</span>
|
||||
<span className="flex-1 text-right text-gray-400 text-sm">
|
||||
{data.originalLanguage}
|
||||
</span>
|
||||
</div>
|
||||
{data.spokenLanguages.some(
|
||||
(lng) => lng.iso_639_1 === data.originalLanguage
|
||||
) && (
|
||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
|
||||
<span className="text-sm">
|
||||
<FormattedMessage {...messages.originallanguage} />
|
||||
</span>
|
||||
<span className="flex-1 text-right text-gray-400 text-sm">
|
||||
{
|
||||
data.spokenLanguages.find(
|
||||
(lng) => lng.iso_639_1 === data.originalLanguage
|
||||
)?.name
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{data.productionCompanies[0] && (
|
||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
|
||||
<span className="text-sm">
|
||||
<FormattedMessage {...messages.studio} />
|
||||
</span>
|
||||
<span className="flex-1 text-right text-gray-400 text-sm">
|
||||
{data.productionCompanies[0]?.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<ExternalLinkBlock
|
||||
mediaType="movie"
|
||||
tmdbId={data.id}
|
||||
imdbId={data.externalIds.imdbId}
|
||||
rtUrl={ratingData?.url}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -527,6 +557,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
items={data?.credits.cast.slice(0, 20).map((person) => (
|
||||
<PersonCard
|
||||
key={`cast-item-${person.id}`}
|
||||
personId={person.id}
|
||||
name={person.name}
|
||||
subName={person.character}
|
||||
profilePath={person.profilePath}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
|
||||
interface PersonCardProps {
|
||||
personId: number;
|
||||
name: string;
|
||||
subName?: string;
|
||||
profilePath?: string;
|
||||
@@ -8,50 +10,55 @@ interface PersonCardProps {
|
||||
}
|
||||
|
||||
const PersonCard: React.FC<PersonCardProps> = ({
|
||||
personId,
|
||||
name,
|
||||
subName,
|
||||
profilePath,
|
||||
canExpand = false,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`relative ${
|
||||
canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'
|
||||
} bg-gray-600 rounded-lg text-white shadow-lg hover:bg-gray-500 transition ease-in-out duration-150 cursor-pointer`}
|
||||
>
|
||||
<div style={{ paddingBottom: '150%' }}>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
{profilePath && (
|
||||
<div
|
||||
style={{
|
||||
backgroundImage: `url(https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath})`,
|
||||
}}
|
||||
className="rounded-full w-28 h-28 md:w-32 md:h-32 bg-cover bg-center mb-6"
|
||||
/>
|
||||
)}
|
||||
{!profilePath && (
|
||||
<svg
|
||||
className="w-28 h-28 md:w-32 md:h-32 mb-6"
|
||||
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-6-3a2 2 0 11-4 0 2 2 0 014 0zm-2 4a5 5 0 00-4.546 2.916A5.986 5.986 0 0010 16a5.986 5.986 0 004.546-2.084A5 5 0 0010 11z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<div className="whitespace-normal text-center">{name}</div>
|
||||
{subName && (
|
||||
<div className="whitespace-normal text-center text-sm text-gray-300">
|
||||
{subName}
|
||||
<Link href={`/person/${personId}`}>
|
||||
<a className={canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'}>
|
||||
<div
|
||||
className={`relative ${
|
||||
canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'
|
||||
} bg-gray-600 rounded-lg text-white shadow-lg hover:bg-gray-500 transition ease-in-out duration-150 cursor-pointer`}
|
||||
>
|
||||
<div style={{ paddingBottom: '150%' }}>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
{profilePath && (
|
||||
<div
|
||||
style={{
|
||||
backgroundImage: `url(https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath})`,
|
||||
}}
|
||||
className="rounded-full w-28 h-28 md:w-32 md:h-32 bg-cover bg-center mb-6"
|
||||
/>
|
||||
)}
|
||||
{!profilePath && (
|
||||
<svg
|
||||
className="w-28 h-28 md:w-32 md:h-32 mb-6"
|
||||
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-6-3a2 2 0 11-4 0 2 2 0 014 0zm-2 4a5 5 0 00-4.546 2.916A5.986 5.986 0 0010 16a5.986 5.986 0 004.546-2.084A5 5 0 0010 11z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<div className="whitespace-normal text-center">{name}</div>
|
||||
{subName && (
|
||||
<div className="whitespace-normal text-center text-sm text-gray-300">
|
||||
{subName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
131
src/components/PersonDetails/index.tsx
Normal file
131
src/components/PersonDetails/index.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useContext } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import type { PersonDetail } from '../../../server/models/Person';
|
||||
import type { PersonCombinedCreditsResponse } from '../../../server/interfaces/api/personInterfaces';
|
||||
import Error from '../../pages/_error';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import TitleCard from '../TitleCard';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
|
||||
const messages = defineMessages({
|
||||
appearsin: 'Appears in',
|
||||
ascharacter: 'as {character}',
|
||||
nobiography: 'No biography available.',
|
||||
});
|
||||
|
||||
const PersonDetails: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const router = useRouter();
|
||||
const { data, error } = useSWR<PersonDetail>(
|
||||
`/api/v1/person/${router.query.personId}`
|
||||
);
|
||||
|
||||
const {
|
||||
data: combinedCredits,
|
||||
error: errorCombinedCredits,
|
||||
} = useSWR<PersonCombinedCreditsResponse>(
|
||||
`/api/v1/person/${router.query.personId}/combined_credits?language=${locale}`
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Error statusCode={404} />;
|
||||
}
|
||||
|
||||
const sortedCast = combinedCredits?.cast.sort((a, b) => {
|
||||
const aDate =
|
||||
a.mediaType === 'movie'
|
||||
? a.releaseDate?.slice(0, 4) ?? 0
|
||||
: a.firstAirDate?.slice(0, 4) ?? 0;
|
||||
const bDate =
|
||||
b.mediaType === 'movie'
|
||||
? b.releaseDate?.slice(0, 4) ?? 0
|
||||
: b.firstAirDate?.slice(0, 4) ?? 0;
|
||||
if (aDate > bDate) {
|
||||
return -1;
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
|
||||
const isLoading = !combinedCredits && !errorCombinedCredits;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex mt-8 mb-8 flex-col md:flex-row items-center md:items-start">
|
||||
{data.profilePath && (
|
||||
<div
|
||||
style={{
|
||||
backgroundImage: `url(https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.profilePath})`,
|
||||
}}
|
||||
className="rounded-full w-36 h-36 md:w-44 md:h-44 bg-cover bg-center mb-6 md:mb-0 mr-0 md:mr-6 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="text-gray-300 text-center md:text-left">
|
||||
<h1 className="text-3xl md:text-4xl text-white mb-4">{data.name}</h1>
|
||||
<div>
|
||||
{data.biography
|
||||
? data.biography
|
||||
: intl.formatMessage(messages.nobiography)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
|
||||
<span>{intl.formatMessage(messages.appearsin)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
|
||||
{sortedCast?.map((media) => {
|
||||
return (
|
||||
<li
|
||||
key={`list-cast-item-${media.id}`}
|
||||
className="col-span-1 flex flex-col text-center items-center"
|
||||
>
|
||||
<TitleCard
|
||||
id={media.id}
|
||||
title={media.mediaType === 'movie' ? media.title : media.name}
|
||||
userScore={media.voteAverage}
|
||||
year={
|
||||
media.mediaType === 'movie'
|
||||
? media.releaseDate
|
||||
: media.firstAirDate
|
||||
}
|
||||
image={media.posterPath}
|
||||
summary={media.overview}
|
||||
mediaType={media.mediaType as 'movie' | 'tv'}
|
||||
status={media.mediaInfo?.status}
|
||||
canExpand
|
||||
/>
|
||||
{media.character && (
|
||||
<div className="mt-2 text-gray-300 text-xs truncate w-36 sm:w-36 md:w-44 text-center">
|
||||
{intl.formatMessage(messages.ascharacter, {
|
||||
character: media.character,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{isLoading &&
|
||||
[...Array(20)].map((_item, i) => (
|
||||
<li
|
||||
key={`placeholder-${i}`}
|
||||
className="col-span-1 flex flex-col text-center items-center"
|
||||
>
|
||||
<TitleCard.Placeholder canExpand />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersonDetails;
|
||||
@@ -12,23 +12,23 @@ const plexOAuth = new PlexOAuth();
|
||||
|
||||
interface PlexLoginButtonProps {
|
||||
onAuthToken: (authToken: string) => void;
|
||||
isProcessing?: boolean;
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
const PlexLoginButton: React.FC<PlexLoginButtonProps> = ({
|
||||
onAuthToken,
|
||||
onError,
|
||||
isProcessing,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const getPlexLogin = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const authToken = await plexOAuth.login();
|
||||
setLoading(false);
|
||||
setIsProcessing(true);
|
||||
onAuthToken(authToken);
|
||||
} catch (e) {
|
||||
if (onError) {
|
||||
|
||||
@@ -43,26 +43,30 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
|
||||
return (
|
||||
<div className="block">
|
||||
<div className="px-4 py-4 sm:px-6 ">
|
||||
<div className="px-4 py-4 sm:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="mr-6 flex items-center text-sm leading-5 text-gray-300">
|
||||
<svg
|
||||
className="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>{request.requestedBy.username}</span>
|
||||
<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">
|
||||
<svg
|
||||
className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="truncate w-40 md:w-auto">
|
||||
{request.requestedBy.username}
|
||||
</span>
|
||||
</div>
|
||||
{request.modifiedBy && (
|
||||
<>
|
||||
<div className="flex flex-nowrap">
|
||||
<svg
|
||||
className="flex-shrink-0 ml-2 mr-1.5 h-5 w-5 text-gray-300"
|
||||
className="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -74,8 +78,10 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>{request.modifiedBy?.username}</span>
|
||||
</>
|
||||
<span className="truncate w-40 md:w-auto">
|
||||
{request.modifiedBy?.username}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-2 flex-shrink-0 flex">
|
||||
@@ -189,13 +195,18 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
</div>
|
||||
</div>
|
||||
{(request.seasons ?? []).length > 0 && (
|
||||
<div className="mt-2 text-sm flex items-center">
|
||||
<span className="mr-2">{intl.formatMessage(messages.seasons)}</span>
|
||||
{request.seasons.map((season) => (
|
||||
<span key={`season-${season.id}`} className="mr-2">
|
||||
<Badge>{season.seasonNumber}</Badge>
|
||||
</span>
|
||||
))}
|
||||
<div className="mt-2 text-sm flex flex-col">
|
||||
<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"
|
||||
>
|
||||
<Badge>{season.seasonNumber}</Badge>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import StatusBadge from '../StatusBadge';
|
||||
const messages = defineMessages({
|
||||
requestedby: 'Requested by {username}',
|
||||
seasons: 'Seasons',
|
||||
all: 'All',
|
||||
});
|
||||
|
||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||
@@ -53,9 +54,11 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
|
||||
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
||||
inView ? `${url}?language=${locale}` : null
|
||||
);
|
||||
const { data: requestData, error: requestError, revalidate } = useSWR<
|
||||
MediaRequest
|
||||
>(`/api/v1/request/${request.id}`, {
|
||||
const {
|
||||
data: requestData,
|
||||
error: requestError,
|
||||
revalidate,
|
||||
} = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, {
|
||||
initialData: request,
|
||||
});
|
||||
|
||||
@@ -103,7 +106,7 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
|
||||
{isMovie(title) ? title.title : title.name}
|
||||
</Link>
|
||||
</h2>
|
||||
<div className="text-xs sm:text-sm">
|
||||
<div className="text-xs sm:text-sm truncate">
|
||||
{intl.formatMessage(messages.requestedby, {
|
||||
username: requestData.requestedBy.username,
|
||||
})}
|
||||
@@ -116,11 +119,21 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
|
||||
{request.seasons.length > 0 && (
|
||||
<div className="hidden mt-2 text-sm sm:flex items-center">
|
||||
<span className="mr-2">{intl.formatMessage(messages.seasons)}</span>
|
||||
{request.seasons.map((season) => (
|
||||
<span key={`season-${season.id}`} className="mr-2">
|
||||
<Badge>{season.seasonNumber}</Badge>
|
||||
{!isMovie(title) &&
|
||||
title.seasons.filter((season) => season.seasonNumber !== 0)
|
||||
.length === request.seasons.length ? (
|
||||
<span className="mr-2 uppercase">
|
||||
<Badge>{intl.formatMessage(messages.all)}</Badge>
|
||||
</span>
|
||||
))}
|
||||
) : (
|
||||
<div className="hide-scrollbar overflow-x-scroll">
|
||||
{request.seasons.map((season) => (
|
||||
<span key={`season-${season.id}`} className="mr-2">
|
||||
<Badge>{season.seasonNumber}</Badge>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{requestData.status === MediaRequestStatus.PENDING &&
|
||||
|
||||
@@ -43,7 +43,6 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
onComplete,
|
||||
tmdbId,
|
||||
onUpdating,
|
||||
...props
|
||||
}) => {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const { addToast } = useToasts();
|
||||
@@ -140,7 +139,6 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
okButtonType={'danger'}
|
||||
cancelText={intl.formatMessage(messages.close)}
|
||||
iconSvg={<DownloadIcon className="w-6 h-6" />}
|
||||
{...props}
|
||||
>
|
||||
{intl.formatMessage(messages.requestfrom, {
|
||||
username: activeRequest.requestedBy.username,
|
||||
@@ -164,7 +162,6 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
}
|
||||
okButtonType={'primary'}
|
||||
iconSvg={<DownloadIcon className="w-6 h-6" />}
|
||||
{...props}
|
||||
>
|
||||
{text}
|
||||
</Modal>
|
||||
|
||||
@@ -47,7 +47,6 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
onComplete,
|
||||
tmdbId,
|
||||
onUpdating,
|
||||
...props
|
||||
}) => {
|
||||
const { addToast } = useToasts();
|
||||
const { data, error } = useSWR<TvDetails>(`/api/v1/tv/${tmdbId}`);
|
||||
@@ -222,7 +221,6 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="-mx-4 sm:mx-0 overflow-auto max-h-96">
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import MovieRequestModal from './MovieRequestModal';
|
||||
import type { MediaStatus } from '../../../server/constants/media';
|
||||
import TvRequestModal from './TvRequestModal';
|
||||
import { useTransition } from 'react-spring';
|
||||
import Transition from '../Transition';
|
||||
|
||||
interface RequestModalProps {
|
||||
show: boolean;
|
||||
@@ -22,49 +22,44 @@ const RequestModal: React.FC<RequestModalProps> = ({
|
||||
onUpdating,
|
||||
onCancel,
|
||||
}) => {
|
||||
const transitions = useTransition(show, null, {
|
||||
from: { opacity: 0, backdropFilter: 'blur(0px)' },
|
||||
enter: { opacity: 1, backdropFilter: 'blur(3px)' },
|
||||
leave: { opacity: 0, backdropFilter: 'blur(0px)' },
|
||||
config: { tension: 500, velocity: 40, friction: 60 },
|
||||
});
|
||||
|
||||
if (type === 'tv') {
|
||||
return (
|
||||
<>
|
||||
{transitions.map(
|
||||
({ props, item, key }) =>
|
||||
item && (
|
||||
<TvRequestModal
|
||||
onComplete={onComplete}
|
||||
onCancel={onCancel}
|
||||
tmdbId={tmdbId}
|
||||
onUpdating={onUpdating}
|
||||
style={props}
|
||||
key={key}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
<Transition
|
||||
enter="transition opacity-0 duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={show}
|
||||
>
|
||||
<TvRequestModal
|
||||
onComplete={onComplete}
|
||||
onCancel={onCancel}
|
||||
tmdbId={tmdbId}
|
||||
onUpdating={onUpdating}
|
||||
/>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{transitions.map(
|
||||
({ props, item, key }) =>
|
||||
item && (
|
||||
<MovieRequestModal
|
||||
onComplete={onComplete}
|
||||
onCancel={onCancel}
|
||||
tmdbId={tmdbId}
|
||||
onUpdating={onUpdating}
|
||||
style={props}
|
||||
key={key}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
<Transition
|
||||
enter="transition opacity-0 duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={show}
|
||||
>
|
||||
<MovieRequestModal
|
||||
onComplete={onComplete}
|
||||
onCancel={onCancel}
|
||||
tmdbId={tmdbId}
|
||||
onUpdating={onUpdating}
|
||||
/>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import ListView from '../Common/ListView';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../Common/Header';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
|
||||
const messages = defineMessages({
|
||||
searchresults: 'Search Results',
|
||||
@@ -66,25 +65,7 @@ const Search: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="md:flex md:items-center md:justify-between mb-6 sm:mb-0 mt-6">
|
||||
<Header>{intl.formatMessage(messages.searchresults)}</Header>
|
||||
<div className="mt-4 flex md:mt-0 md:ml-4">
|
||||
<span className="relative z-0 inline-flex shadow-sm rounded-md">
|
||||
<button
|
||||
type="button"
|
||||
className="relative inline-flex items-center px-4 py-2 rounded-l-md border border-indigo-900 bg-indigo-500 hover:bg-indigo-400 text-sm leading-5 font-medium text-gray-100 hover:text-white focus:z-10 focus:outline-none focus:border-blue-300 focus:ring-blue active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"
|
||||
>
|
||||
{intl.formatMessage(globalMessages.movies)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-px relative inline-flex items-center px-4 py-2 rounded-r-md border border-indigo-900 bg-indigo-500 text-sm leading-5 font-medium text-gray-100 hover:text-white focus:z-10 focus:outline-none focus:border-blue-300 focus:ring-blue active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"
|
||||
>
|
||||
{intl.formatMessage(globalMessages.tvshows)}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Header>{intl.formatMessage(messages.searchresults)}</Header>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
|
||||
@@ -25,7 +25,10 @@ const CopyButton: React.FC<{ textToCopy: string }> = ({ textToCopy }) => {
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={setCopied}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setCopied();
|
||||
}}
|
||||
className="-ml-px relative inline-flex items-center px-4 py-2 border border-gray-500 text-sm leading-5 font-medium text-white bg-indigo-500 hover:bg-indigo-400 focus:outline-none focus:ring-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
|
||||
const messages = defineMessages({
|
||||
save: 'Save Changes',
|
||||
@@ -14,10 +15,13 @@ const messages = defineMessages({
|
||||
webhookUrl: 'Webhook URL',
|
||||
validationWebhookUrlRequired: 'You must provide a webhook URL',
|
||||
webhookUrlPlaceholder: 'Server Settings -> Integrations -> Webhooks',
|
||||
discordsettingssaved: 'Discord notification settings saved!',
|
||||
discordsettingsfailed: 'Discord notification settings failed to save.',
|
||||
});
|
||||
|
||||
const NotificationsDiscord: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const { data, error, revalidate } = useSWR(
|
||||
'/api/v1/settings/notifications/discord'
|
||||
);
|
||||
@@ -49,8 +53,15 @@ const NotificationsDiscord: React.FC = () => {
|
||||
webhookUrl: values.webhookUrl,
|
||||
},
|
||||
});
|
||||
addToast(intl.formatMessage(messages.discordsettingssaved), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} catch (e) {
|
||||
// TODO show error
|
||||
addToast(intl.formatMessage(messages.discordsettingsfailed), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} finally {
|
||||
revalidate();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
|
||||
const messages = defineMessages({
|
||||
save: 'Save Changes',
|
||||
@@ -20,10 +21,13 @@ const messages = defineMessages({
|
||||
enableSsl: 'Enable SSL',
|
||||
authUser: 'Auth User',
|
||||
authPass: 'Auth Pass',
|
||||
emailsettingssaved: 'Email notification settings saved!',
|
||||
emailsettingsfailed: 'Email notification settings failed to save.',
|
||||
});
|
||||
|
||||
const NotificationsEmail: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const { data, error, revalidate } = useSWR(
|
||||
'/api/v1/settings/notifications/email'
|
||||
);
|
||||
@@ -65,14 +69,21 @@ const NotificationsEmail: React.FC = () => {
|
||||
options: {
|
||||
emailFrom: values.emailFrom,
|
||||
smtpHost: values.smtpHost,
|
||||
smtpPort: values.smtpPort,
|
||||
smtpPort: Number(values.smtpPort),
|
||||
secure: values.secure,
|
||||
authUser: values.authUser,
|
||||
authPass: values.authPass,
|
||||
},
|
||||
});
|
||||
addToast(intl.formatMessage(messages.emailsettingssaved), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} catch (e) {
|
||||
// TODO show error
|
||||
addToast(intl.formatMessage(messages.emailsettingsfailed), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} finally {
|
||||
revalidate();
|
||||
}
|
||||
@@ -86,7 +97,7 @@ const NotificationsEmail: React.FC = () => {
|
||||
htmlFor="isDefault"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
Agent Enabled
|
||||
{intl.formatMessage(messages.agentenabled)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<Field
|
||||
|
||||
@@ -11,11 +11,13 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||
const messages = defineMessages({
|
||||
createradarr: 'Create New Radarr Server',
|
||||
editradarr: 'Edit Radarr Server',
|
||||
validationNameRequired: 'You must provide a server name',
|
||||
validationHostnameRequired: 'You must provide a hostname/IP',
|
||||
validationPortRequired: 'You must provide a port',
|
||||
validationApiKeyRequired: 'You must provide an API key',
|
||||
validationRootFolderRequired: 'You must select a root folder',
|
||||
validationProfileRequired: 'You must select a profile',
|
||||
validationMinimumAvailabilityRequired: 'You must select minimum availability',
|
||||
toastRadarrTestSuccess: 'Radarr connection established!',
|
||||
toastRadarrTestFailure: 'Failed to connect to Radarr Server',
|
||||
saving: 'Saving...',
|
||||
@@ -40,6 +42,10 @@ const messages = defineMessages({
|
||||
selectQualityProfile: 'Select a Quality Profile',
|
||||
selectRootFolder: 'Select a Root Folder',
|
||||
selectMinimumAvailability: 'Select minimum availability',
|
||||
loadingprofiles: 'Loading quality profiles…',
|
||||
testFirstQualityProfiles: 'Test your connection to load quality profiles',
|
||||
loadingrootfolders: 'Loading root folders…',
|
||||
testFirstRootFolders: 'Test your connection to load root folders',
|
||||
});
|
||||
|
||||
interface TestResponse {
|
||||
@@ -74,6 +80,9 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
rootFolders: [],
|
||||
});
|
||||
const RadarrSettingsSchema = Yup.object().shape({
|
||||
name: Yup.string().required(
|
||||
intl.formatMessage(messages.validationNameRequired)
|
||||
),
|
||||
hostname: Yup.string().required(
|
||||
intl.formatMessage(messages.validationHostnameRequired)
|
||||
),
|
||||
@@ -81,10 +90,15 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
intl.formatMessage(messages.validationPortRequired)
|
||||
),
|
||||
apiKey: Yup.string().required(intl.formatMessage(messages.apiKey)),
|
||||
rootFolder: Yup.string().required(intl.formatMessage(messages.rootfolder)),
|
||||
rootFolder: Yup.string().required(
|
||||
intl.formatMessage(messages.validationRootFolderRequired)
|
||||
),
|
||||
activeProfileId: Yup.string().required(
|
||||
intl.formatMessage(messages.validationProfileRequired)
|
||||
),
|
||||
minimumAvailability: Yup.string().required(
|
||||
intl.formatMessage(messages.validationMinimumAvailabilityRequired)
|
||||
),
|
||||
});
|
||||
|
||||
const testConnection = useCallback(
|
||||
@@ -108,7 +122,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
{
|
||||
hostname,
|
||||
apiKey,
|
||||
port,
|
||||
port: Number(port),
|
||||
baseUrl,
|
||||
useSsl,
|
||||
}
|
||||
@@ -171,7 +185,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
baseUrl: radarr?.baseUrl,
|
||||
activeProfileId: radarr?.activeProfileId,
|
||||
rootFolder: radarr?.activeDirectory,
|
||||
minimumAvailability: radarr?.minimumAvailability,
|
||||
minimumAvailability: radarr?.minimumAvailability ?? 'released',
|
||||
isDefault: radarr?.isDefault ?? false,
|
||||
is4k: radarr?.is4k ?? false,
|
||||
}}
|
||||
@@ -185,11 +199,11 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
const submission = {
|
||||
name: values.name,
|
||||
hostname: values.hostname,
|
||||
port: values.port,
|
||||
port: Number(values.port),
|
||||
apiKey: values.apiKey,
|
||||
useSsl: values.ssl,
|
||||
baseUrl: values.baseUrl,
|
||||
activeProfileId: values.activeProfileId,
|
||||
activeProfileId: Number(values.activeProfileId),
|
||||
activeProfileName: profileName,
|
||||
activeDirectory: values.rootFolder,
|
||||
is4k: values.is4k,
|
||||
@@ -218,6 +232,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
handleSubmit,
|
||||
setFieldValue,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
@@ -250,7 +265,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
secondaryDisabled={
|
||||
!values.apiKey || !values.hostname || !values.port || isTesting
|
||||
}
|
||||
okDisabled={!isValidated || isSubmitting || isTesting}
|
||||
okDisabled={!isValidated || isSubmitting || isTesting || !isValid}
|
||||
onOk={() => handleSubmit()}
|
||||
title={
|
||||
!radarr
|
||||
@@ -312,6 +327,9 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
</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">
|
||||
{values.ssl ? 'https://' : 'http://'}
|
||||
</span>
|
||||
<Field
|
||||
id="hostname"
|
||||
name="hostname"
|
||||
@@ -321,7 +339,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
setIsValidated(false);
|
||||
setFieldValue('hostname', 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 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"
|
||||
/>
|
||||
</div>
|
||||
{errors.hostname && touched.hostname && (
|
||||
@@ -442,10 +460,17 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
as="select"
|
||||
id="activeProfileId"
|
||||
name="activeProfileId"
|
||||
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={!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"
|
||||
>
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.selectQualityProfile)}
|
||||
{isTesting
|
||||
? intl.formatMessage(messages.loadingprofiles)
|
||||
: !isValidated
|
||||
? intl.formatMessage(
|
||||
messages.testFirstQualityProfiles
|
||||
)
|
||||
: intl.formatMessage(messages.selectQualityProfile)}
|
||||
</option>
|
||||
{testResponse.profiles.length > 0 &&
|
||||
testResponse.profiles.map((profile) => (
|
||||
@@ -478,10 +503,15 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
as="select"
|
||||
id="rootFolder"
|
||||
name="rootFolder"
|
||||
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={!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"
|
||||
>
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.selectRootFolder)}
|
||||
{isTesting
|
||||
? intl.formatMessage(messages.loadingrootfolders)
|
||||
: !isValidated
|
||||
? intl.formatMessage(messages.testFirstRootFolders)
|
||||
: intl.formatMessage(messages.selectRootFolder)}
|
||||
</option>
|
||||
{testResponse.rootFolders.length > 0 &&
|
||||
testResponse.rootFolders.map((folder) => (
|
||||
@@ -516,17 +546,18 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
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"
|
||||
>
|
||||
<option value="">
|
||||
{intl.formatMessage(
|
||||
messages.selectMinimumAvailability
|
||||
)}
|
||||
</option>
|
||||
<option value="announced">Announced</option>
|
||||
<option value="inCinemas">In Cinemas</option>
|
||||
<option value="released">Released</option>
|
||||
<option value="preDB">PreDB</option>
|
||||
</Field>
|
||||
</div>
|
||||
{errors.minimumAvailability &&
|
||||
touched.minimumAvailability && (
|
||||
<div className="text-red-500 mt-2">
|
||||
{errors.minimumAvailability}
|
||||
</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-200 sm:pt-5">
|
||||
|
||||
86
src/components/Settings/SettingsAbout/index.tsx
Normal file
86
src/components/Settings/SettingsAbout/index.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import useSWR from 'swr';
|
||||
import Error from '../../../pages/_error';
|
||||
import List from '../../Common/List';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import { SettingsAboutResponse } from '../../../../server/interfaces/api/settingsInterfaces';
|
||||
import { defineMessages, FormattedNumber, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
overseerrinformation: 'Overseerr Information',
|
||||
version: 'Version',
|
||||
totalmedia: 'Total Media',
|
||||
totalrequests: 'Total Requests',
|
||||
gettingsupport: 'Getting Support',
|
||||
githubdiscussions: 'GitHub Discussions',
|
||||
clickheretojoindiscord: 'Click here to join our Discord server.',
|
||||
timezone: 'Timezone',
|
||||
});
|
||||
|
||||
const SettingsAbout: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { data, error } = useSWR<SettingsAboutResponse>(
|
||||
'/api/v1/settings/about'
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<List title={intl.formatMessage(messages.overseerrinformation)}>
|
||||
<List.Item title={intl.formatMessage(messages.version)}>
|
||||
{data.version}
|
||||
</List.Item>
|
||||
<List.Item title={intl.formatMessage(messages.totalmedia)}>
|
||||
<FormattedNumber value={data.totalMediaItems} />
|
||||
</List.Item>
|
||||
<List.Item title={intl.formatMessage(messages.totalrequests)}>
|
||||
<FormattedNumber value={data.totalRequests} />
|
||||
</List.Item>
|
||||
{data.tz && (
|
||||
<List.Item title={intl.formatMessage(messages.timezone)}>
|
||||
{data.tz}
|
||||
</List.Item>
|
||||
)}
|
||||
</List>
|
||||
</div>
|
||||
<div className="mb-8">
|
||||
<List title={intl.formatMessage(messages.gettingsupport)}>
|
||||
<List.Item title={intl.formatMessage(messages.githubdiscussions)}>
|
||||
<a
|
||||
href="https://github.com/sct/overseerr/discussions"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-indigo-500 hover:underline"
|
||||
>
|
||||
https://github.com/sct/overseerr/discussions
|
||||
</a>
|
||||
</List.Item>
|
||||
<List.Item title="Discord">
|
||||
<a
|
||||
href="https://discord.gg/PkCWJSeCk7"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-indigo-500 hover:underline"
|
||||
>
|
||||
{intl.formatMessage(messages.clickheretojoindiscord)}
|
||||
</a>
|
||||
</List.Item>
|
||||
</List>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsAbout;
|
||||
@@ -46,7 +46,7 @@ const SettingsJobs: React.FC = () => {
|
||||
</div>
|
||||
</Table.TD>
|
||||
<Table.TD alignText="right">
|
||||
<Button buttonType="primary">
|
||||
<Button buttonType="primary" disabled>
|
||||
{intl.formatMessage(messages.runnow)}
|
||||
</Button>
|
||||
</Table.TD>
|
||||
|
||||
17
src/components/Settings/SettingsLogs/index.tsx
Normal file
17
src/components/Settings/SettingsLogs/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
// We will localize this file when the complete version is released.
|
||||
|
||||
const SettingsLogs: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="leading-loose text-gray-300 text-sm">
|
||||
Logs page is still being built. For now, you can access your logs
|
||||
directly in <code>stdout</code> (container logs) or looking in{' '}
|
||||
<code>/app/config/logs/overseerr.logs</code>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsLogs;
|
||||
@@ -8,6 +8,7 @@ import axios from 'axios';
|
||||
import Button from '../Common/Button';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useUser, Permission } from '../../hooks/useUser';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
|
||||
const messages = defineMessages({
|
||||
generalsettings: 'General Settings',
|
||||
@@ -17,15 +18,37 @@ const messages = defineMessages({
|
||||
saving: 'Saving...',
|
||||
apikey: 'API Key',
|
||||
applicationurl: 'Application URL',
|
||||
toastApiKeySuccess: 'New API Key generated!',
|
||||
toastApiKeyFailure: 'Something went wrong generating a new API Key.',
|
||||
toastSettingsSuccess: 'Settings saved.',
|
||||
toastSettingsFailure: 'Something went wrong saving settings.',
|
||||
});
|
||||
|
||||
const SettingsMain: React.FC = () => {
|
||||
const { addToast } = useToasts();
|
||||
const { hasPermission } = useUser();
|
||||
const intl = useIntl();
|
||||
const { data, error, revalidate } = useSWR<MainSettings>(
|
||||
'/api/v1/settings/main'
|
||||
);
|
||||
|
||||
const regenerate = async () => {
|
||||
try {
|
||||
await axios.get('/api/v1/settings/main/regenerate');
|
||||
|
||||
revalidate();
|
||||
addToast(intl.formatMessage(messages.toastApiKeySuccess), {
|
||||
autoDismiss: true,
|
||||
appearance: 'success',
|
||||
});
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.toastApiKeyFailure), {
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -50,8 +73,16 @@ const SettingsMain: React.FC = () => {
|
||||
await axios.post('/api/v1/settings/main', {
|
||||
applicationUrl: values.applicationUrl,
|
||||
});
|
||||
|
||||
addToast(intl.formatMessage(messages.toastSettingsSuccess), {
|
||||
autoDismiss: true,
|
||||
appearance: 'success',
|
||||
});
|
||||
} catch (e) {
|
||||
// TODO show error
|
||||
addToast(intl.formatMessage(messages.toastSettingsFailure), {
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
});
|
||||
} finally {
|
||||
revalidate();
|
||||
}
|
||||
@@ -77,8 +108,17 @@ const SettingsMain: React.FC = () => {
|
||||
value={data?.apiKey}
|
||||
readOnly
|
||||
/>
|
||||
<CopyButton textToCopy={data?.apiKey ?? ''} />
|
||||
<button className="-ml-px relative inline-flex items-center px-4 py-2 border border-gray-500 text-sm leading-5 font-medium rounded-r-md text-white bg-indigo-500 hover:bg-indigo-400 focus:outline-none focus:ring-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150">
|
||||
<CopyButton
|
||||
textToCopy={data?.apiKey ?? ''}
|
||||
key={data?.apiKey}
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
regenerate();
|
||||
}}
|
||||
className="-ml-px relative inline-flex items-center px-4 py-2 border border-gray-500 text-sm leading-5 font-medium rounded-r-md text-white bg-indigo-500 hover:bg-indigo-400 focus:outline-none focus:ring-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
|
||||
@@ -2,21 +2,23 @@ import React, { useState } from 'react';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import type { PlexSettings } from '../../../server/lib/settings';
|
||||
import useSWR from 'swr';
|
||||
import { useFormik } from 'formik';
|
||||
import { Formik, Field } from 'formik';
|
||||
import Button from '../Common/Button';
|
||||
import axios from 'axios';
|
||||
import LibraryItem from './LibraryItem';
|
||||
import Badge from '../Common/Badge';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const messages = defineMessages({
|
||||
plexsettings: 'Plex Settings',
|
||||
plexsettingsDescription:
|
||||
'Configure the settings for your Plex server. Overseerr uses your Plex server to scan your library at an interval and see what content is available.',
|
||||
servername: 'Server Name (Automatically Set)',
|
||||
servername: 'Server Name (Automatically set after you save)',
|
||||
servernamePlaceholder: 'Plex Server Name',
|
||||
hostname: 'Hostname/IP',
|
||||
port: 'Port',
|
||||
ssl: 'SSL',
|
||||
save: 'Save Changes',
|
||||
saving: 'Saving...',
|
||||
plexlibraries: 'Plex Libraries',
|
||||
@@ -32,6 +34,8 @@ const messages = defineMessages({
|
||||
librariesRemaining: 'Libraries Remaining: {count}',
|
||||
startscan: 'Start Scan',
|
||||
cancelscan: 'Cancel Scan',
|
||||
validationHostnameRequired: 'You must provide a hostname/IP',
|
||||
validationPortRequired: 'You must provide a port',
|
||||
});
|
||||
|
||||
interface Library {
|
||||
@@ -64,33 +68,15 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
|
||||
}
|
||||
);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
hostname: data?.ip,
|
||||
port: data?.port,
|
||||
},
|
||||
enableReinitialize: true,
|
||||
onSubmit: async (values) => {
|
||||
setSubmitError(null);
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await axios.post('/api/v1/settings/plex', {
|
||||
ip: values.hostname,
|
||||
port: values.port,
|
||||
} as PlexSettings);
|
||||
|
||||
revalidate();
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
} catch (e) {
|
||||
setSubmitError(e.response.data.message);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
},
|
||||
const PlexSettingsSchema = Yup.object().shape({
|
||||
hostname: Yup.string().required(
|
||||
intl.formatMessage(messages.validationHostnameRequired)
|
||||
),
|
||||
port: Yup.number().required(
|
||||
intl.formatMessage(messages.validationPortRequired)
|
||||
),
|
||||
});
|
||||
|
||||
const activeLibraries =
|
||||
@@ -164,91 +150,157 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
|
||||
<FormattedMessage {...messages.plexsettingsDescription} />
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<div className="mt-6 sm:mt-5">
|
||||
{submitError && (
|
||||
<div className="bg-red-700 text-white p-4 rounded-md mb-6">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
<FormattedMessage {...messages.servername} />
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder={intl.formatMessage(
|
||||
messages.servernamePlaceholder
|
||||
)}
|
||||
value={data?.name}
|
||||
readOnly
|
||||
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"
|
||||
/>
|
||||
<Formik
|
||||
initialValues={{
|
||||
hostname: data?.ip,
|
||||
port: data?.port,
|
||||
useSsl: data?.useSsl,
|
||||
}}
|
||||
enableReinitialize
|
||||
validationSchema={PlexSettingsSchema}
|
||||
onSubmit={async (values) => {
|
||||
setSubmitError(null);
|
||||
try {
|
||||
await axios.post('/api/v1/settings/plex', {
|
||||
ip: values.hostname,
|
||||
port: Number(values.port),
|
||||
useSsl: values.useSsl,
|
||||
} as PlexSettings);
|
||||
|
||||
revalidate();
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
} catch (e) {
|
||||
setSubmitError(e.response.data.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({
|
||||
errors,
|
||||
touched,
|
||||
values,
|
||||
handleSubmit,
|
||||
setFieldValue,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mt-6 sm:mt-5">
|
||||
{submitError && (
|
||||
<div className="bg-red-700 text-white p-4 rounded-md mb-6">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
<FormattedMessage {...messages.servername} />
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder={intl.formatMessage(
|
||||
messages.servernamePlaceholder
|
||||
)}
|
||||
value={data?.name}
|
||||
readOnly
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
|
||||
<label
|
||||
htmlFor="hostname"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
<FormattedMessage {...messages.hostname} />
|
||||
</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-800 text-gray-100 sm:text-sm cursor-default">
|
||||
{values.useSsl ? 'https://' : 'http://'}
|
||||
</span>
|
||||
<Field
|
||||
type="text"
|
||||
id="hostname"
|
||||
name="hostname"
|
||||
placeholder="127.0.0.1"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
{errors.hostname && touched.hostname && (
|
||||
<div className="text-red-500 mt-2">{errors.hostname}</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-200 sm:pt-5">
|
||||
<label
|
||||
htmlFor="port"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
<FormattedMessage {...messages.port} />
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg rounded-md shadow-sm sm:max-w-xs">
|
||||
<Field
|
||||
type="text"
|
||||
id="port"
|
||||
name="port"
|
||||
placeholder="32400"
|
||||
className="form-input block w-24 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
/>
|
||||
</div>
|
||||
{errors.port && touched.port && (
|
||||
<div className="text-red-500 mt-2">{errors.port}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
|
||||
<label
|
||||
htmlFor="hostname"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
<FormattedMessage {...messages.hostname} />
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<input
|
||||
type="text"
|
||||
id="hostname"
|
||||
name="hostname"
|
||||
placeholder="127.0.0.1"
|
||||
value={formik.values.hostname}
|
||||
onChange={formik.handleChange}
|
||||
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"
|
||||
/>
|
||||
<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-200 sm:pt-5">
|
||||
<label
|
||||
htmlFor="ssl"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
{intl.formatMessage(messages.ssl)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="useSsl"
|
||||
name="useSsl"
|
||||
onChange={() => {
|
||||
setFieldValue('useSsl', !values.useSsl);
|
||||
}}
|
||||
className="form-checkbox h-6 w-6 rounded-md text-indigo-600 transition duration-150 ease-in-out"
|
||||
/>
|
||||
</div>
|
||||
</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-200 sm:pt-5">
|
||||
<label
|
||||
htmlFor="port"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
<FormattedMessage {...messages.port} />
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg rounded-md shadow-sm sm:max-w-xs">
|
||||
<input
|
||||
type="text"
|
||||
id="port"
|
||||
name="port"
|
||||
placeholder="32400"
|
||||
value={formik.values.port}
|
||||
onChange={formik.handleChange}
|
||||
className="form-input block w-24 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
/>
|
||||
<div className="mt-8 border-t border-gray-700 pt-5">
|
||||
<div className="flex justify-end">
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.saving)
|
||||
: intl.formatMessage(messages.save)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 border-t border-gray-700 pt-5">
|
||||
<div className="flex justify-end">
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
<Button buttonType="primary" type="submit" disabled={isUpdating}>
|
||||
{isUpdating
|
||||
? intl.formatMessage(messages.saving)
|
||||
: intl.formatMessage(messages.save)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
<div className="mt-10">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-200">
|
||||
<FormattedMessage {...messages.plexlibraries} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import Badge from '../Common/Badge';
|
||||
import Button from '../Common/Button';
|
||||
import useSWR from 'swr';
|
||||
@@ -31,8 +31,48 @@ const messages = defineMessages({
|
||||
activeProfile: 'Active Profile',
|
||||
addradarr: 'Add Radarr Server',
|
||||
addsonarr: 'Add Sonarr Server',
|
||||
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)',
|
||||
});
|
||||
|
||||
const NoDefaultAlert: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<div className="rounded-md bg-yellow-600 p-4 mb-8">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="h-5 w-5 text-yellow-200"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-200">
|
||||
{intl.formatMessage(messages.nodefault)}
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-yellow-300">
|
||||
<p>{intl.formatMessage(messages.nodefaultdescription)}</p>
|
||||
<p className="mt-2">
|
||||
{intl.formatMessage(messages.no4kimplemented)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ServerInstanceProps {
|
||||
name: string;
|
||||
isDefault?: boolean;
|
||||
@@ -249,51 +289,57 @@ const SettingsServices: React.FC = () => {
|
||||
<div className="mt-6 sm:mt-5">
|
||||
{!radarrData && !radarrError && <LoadingSpinner />}
|
||||
{radarrData && !radarrError && (
|
||||
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{radarrData.map((radarr) => (
|
||||
<ServerInstance
|
||||
key={`radarr-config-${radarr.id}`}
|
||||
name={radarr.name}
|
||||
address={radarr.hostname}
|
||||
profileName={radarr.activeProfileName}
|
||||
isSSL={radarr.useSsl}
|
||||
isDefault={radarr.isDefault && !radarr.is4k}
|
||||
isDefault4K={radarr.is4k && radarr.isDefault}
|
||||
onEdit={() => setEditRadarrModal({ open: true, radarr })}
|
||||
onDelete={() =>
|
||||
setDeleteServerModal({
|
||||
open: true,
|
||||
serverId: radarr.id,
|
||||
type: 'radarr',
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
onClick={() =>
|
||||
setEditRadarrModal({ open: true, radarr: null })
|
||||
<>
|
||||
{radarrData.length > 0 &&
|
||||
!radarrData.some(
|
||||
(radarr) => radarr.isDefault && !radarr.is4k
|
||||
) && <NoDefaultAlert />}
|
||||
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{radarrData.map((radarr) => (
|
||||
<ServerInstance
|
||||
key={`radarr-config-${radarr.id}`}
|
||||
name={radarr.name}
|
||||
address={radarr.hostname}
|
||||
profileName={radarr.activeProfileName}
|
||||
isSSL={radarr.useSsl}
|
||||
isDefault={radarr.isDefault && !radarr.is4k}
|
||||
isDefault4K={radarr.is4k && radarr.isDefault}
|
||||
onEdit={() => setEditRadarrModal({ open: true, radarr })}
|
||||
onDelete={() =>
|
||||
setDeleteServerModal({
|
||||
open: true,
|
||||
serverId: radarr.id,
|
||||
type: 'radarr',
|
||||
})
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
/>
|
||||
))}
|
||||
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
onClick={() =>
|
||||
setEditRadarrModal({ open: true, radarr: null })
|
||||
}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedMessage {...messages.addradarr} />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<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="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedMessage {...messages.addradarr} />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-10">
|
||||
@@ -307,52 +353,58 @@ const SettingsServices: React.FC = () => {
|
||||
<div className="mt-6 sm:mt-5">
|
||||
{!sonarrData && !sonarrError && <LoadingSpinner />}
|
||||
{sonarrData && !sonarrError && (
|
||||
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sonarrData.map((sonarr) => (
|
||||
<ServerInstance
|
||||
key={`sonarr-config-${sonarr.id}`}
|
||||
name={sonarr.name}
|
||||
address={sonarr.hostname}
|
||||
profileName={sonarr.activeProfileName}
|
||||
isSSL={sonarr.useSsl}
|
||||
isSonarr
|
||||
isDefault4K={sonarr.isDefault && sonarr.is4k}
|
||||
isDefault={sonarr.isDefault && !sonarr.is4k}
|
||||
onEdit={() => setEditSonarrModal({ open: true, sonarr })}
|
||||
onDelete={() =>
|
||||
setDeleteServerModal({
|
||||
open: true,
|
||||
serverId: sonarr.id,
|
||||
type: 'sonarr',
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
onClick={() =>
|
||||
setEditSonarrModal({ open: true, sonarr: null })
|
||||
<>
|
||||
{sonarrData.length > 0 &&
|
||||
!sonarrData.some(
|
||||
(sonarr) => sonarr.isDefault && !sonarr.is4k
|
||||
) && <NoDefaultAlert />}
|
||||
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sonarrData.map((sonarr) => (
|
||||
<ServerInstance
|
||||
key={`sonarr-config-${sonarr.id}`}
|
||||
name={sonarr.name}
|
||||
address={sonarr.hostname}
|
||||
profileName={sonarr.activeProfileName}
|
||||
isSSL={sonarr.useSsl}
|
||||
isSonarr
|
||||
isDefault4K={sonarr.isDefault && sonarr.is4k}
|
||||
isDefault={sonarr.isDefault && !sonarr.is4k}
|
||||
onEdit={() => setEditSonarrModal({ open: true, sonarr })}
|
||||
onDelete={() =>
|
||||
setDeleteServerModal({
|
||||
open: true,
|
||||
serverId: sonarr.id,
|
||||
type: 'sonarr',
|
||||
})
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
/>
|
||||
))}
|
||||
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
onClick={() =>
|
||||
setEditSonarrModal({ open: true, sonarr: null })
|
||||
}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedMessage {...messages.addsonarr} />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<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="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedMessage {...messages.addsonarr} />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useIntl, defineMessages } from 'react-intl';
|
||||
const messages = defineMessages({
|
||||
createsonarr: 'Create New Sonarr Server',
|
||||
editsonarr: 'Edit Sonarr Server',
|
||||
validationNameRequired: 'You must provide a server name',
|
||||
validationHostnameRequired: 'You must provide a hostname/IP',
|
||||
validationPortRequired: 'You must provide a port',
|
||||
validationApiKeyRequired: 'You must provide an API key',
|
||||
@@ -35,10 +36,16 @@ const messages = defineMessages({
|
||||
baseUrlPlaceholder: 'Example: /sonarr',
|
||||
qualityprofile: 'Quality Profile',
|
||||
rootfolder: 'Root Folder',
|
||||
animequalityprofile: 'Anime Quality Profile',
|
||||
animerootfolder: 'Anime Root Folder',
|
||||
seasonfolders: 'Season Folders',
|
||||
server4k: '4K Server',
|
||||
selectQualityProfile: 'Select a Quality Profile',
|
||||
selectRootFolder: 'Select a Root Folder',
|
||||
loadingprofiles: 'Loading quality profiles…',
|
||||
testFirstQualityProfiles: 'Test your connection to load quality profiles',
|
||||
loadingrootfolders: 'Loading root folders…',
|
||||
testFirstRootFolders: 'Test your connection to load root folders',
|
||||
});
|
||||
|
||||
interface TestResponse {
|
||||
@@ -73,6 +80,9 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
rootFolders: [],
|
||||
});
|
||||
const SonarrSettingsSchema = Yup.object().shape({
|
||||
name: Yup.string().required(
|
||||
intl.formatMessage(messages.validationNameRequired)
|
||||
),
|
||||
hostname: Yup.string().required(
|
||||
intl.formatMessage(messages.validationHostnameRequired)
|
||||
),
|
||||
@@ -111,7 +121,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
{
|
||||
hostname,
|
||||
apiKey,
|
||||
port,
|
||||
port: Number(port),
|
||||
baseUrl,
|
||||
useSsl,
|
||||
}
|
||||
@@ -174,6 +184,8 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
baseUrl: sonarr?.baseUrl,
|
||||
activeProfileId: sonarr?.activeProfileId,
|
||||
rootFolder: sonarr?.activeDirectory,
|
||||
activeAnimeProfileId: sonarr?.activeAnimeProfileId,
|
||||
activeAnimeRootFolder: sonarr?.activeAnimeDirectory,
|
||||
isDefault: sonarr?.isDefault ?? false,
|
||||
is4k: sonarr?.is4k ?? false,
|
||||
enableSeasonFolders: sonarr?.enableSeasonFolders ?? false,
|
||||
@@ -184,17 +196,25 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
const profileName = testResponse.profiles.find(
|
||||
(profile) => profile.id === Number(values.activeProfileId)
|
||||
)?.name;
|
||||
const animeProfileName = testResponse.profiles.find(
|
||||
(profile) => profile.id === Number(values.activeAnimeProfileId)
|
||||
)?.name;
|
||||
|
||||
const submission = {
|
||||
name: values.name,
|
||||
hostname: values.hostname,
|
||||
port: values.port,
|
||||
port: Number(values.port),
|
||||
apiKey: values.apiKey,
|
||||
useSsl: values.ssl,
|
||||
baseUrl: values.baseUrl,
|
||||
activeProfileId: values.activeProfileId,
|
||||
activeProfileId: Number(values.activeProfileId),
|
||||
activeProfileName: profileName,
|
||||
activeDirectory: values.rootFolder,
|
||||
activeAnimeProfileId: values.activeAnimeProfileId
|
||||
? Number(values.activeAnimeProfileId)
|
||||
: undefined,
|
||||
activeAnimeProfileName: animeProfileName ?? undefined,
|
||||
activeAnimeDirectory: values.activeAnimeRootFolder,
|
||||
is4k: values.is4k,
|
||||
isDefault: values.isDefault,
|
||||
enableSeasonFolders: values.enableSeasonFolders,
|
||||
@@ -221,6 +241,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
handleSubmit,
|
||||
setFieldValue,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
@@ -253,7 +274,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
secondaryDisabled={
|
||||
!values.apiKey || !values.hostname || !values.port || isTesting
|
||||
}
|
||||
okDisabled={!isValidated || isSubmitting || isTesting}
|
||||
okDisabled={!isValidated || isSubmitting || isTesting || !isValid}
|
||||
onOk={() => handleSubmit()}
|
||||
title={
|
||||
!sonarr
|
||||
@@ -315,6 +336,9 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
</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">
|
||||
{values.ssl ? 'https://' : 'http://'}
|
||||
</span>
|
||||
<Field
|
||||
id="hostname"
|
||||
name="hostname"
|
||||
@@ -324,7 +348,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
setIsValidated(false);
|
||||
setFieldValue('hostname', 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 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"
|
||||
/>
|
||||
</div>
|
||||
{errors.hostname && touched.hostname && (
|
||||
@@ -445,10 +469,17 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
as="select"
|
||||
id="activeProfileId"
|
||||
name="activeProfileId"
|
||||
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={!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"
|
||||
>
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.selectQualityProfile)}
|
||||
{isTesting
|
||||
? intl.formatMessage(messages.loadingprofiles)
|
||||
: !isValidated
|
||||
? intl.formatMessage(
|
||||
messages.testFirstQualityProfiles
|
||||
)
|
||||
: intl.formatMessage(messages.selectQualityProfile)}
|
||||
</option>
|
||||
{testResponse.profiles.length > 0 &&
|
||||
testResponse.profiles.map((profile) => (
|
||||
@@ -481,10 +512,15 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
as="select"
|
||||
id="rootFolder"
|
||||
name="rootFolder"
|
||||
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={!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"
|
||||
>
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.selectRootFolder)}
|
||||
{isTesting
|
||||
? intl.formatMessage(messages.loadingrootfolders)
|
||||
: !isValidated
|
||||
? intl.formatMessage(messages.testFirstRootFolders)
|
||||
: intl.formatMessage(messages.selectRootFolder)}
|
||||
</option>
|
||||
{testResponse.rootFolders.length > 0 &&
|
||||
testResponse.rootFolders.map((folder) => (
|
||||
@@ -504,6 +540,92 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
)}
|
||||
</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 sm:pt-5">
|
||||
<label
|
||||
htmlFor="activeAnimeProfileId"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
{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">
|
||||
<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"
|
||||
>
|
||||
<option value="">
|
||||
{isTesting
|
||||
? intl.formatMessage(messages.loadingprofiles)
|
||||
: !isValidated
|
||||
? intl.formatMessage(
|
||||
messages.testFirstQualityProfiles
|
||||
)
|
||||
: intl.formatMessage(messages.selectQualityProfile)}
|
||||
</option>
|
||||
{testResponse.profiles.length > 0 &&
|
||||
testResponse.profiles.map((profile) => (
|
||||
<option
|
||||
key={`loaded-profile-${profile.id}`}
|
||||
value={profile.id}
|
||||
>
|
||||
{profile.name}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</div>
|
||||
{errors.activeAnimeProfileId &&
|
||||
touched.activeAnimeProfileId && (
|
||||
<div className="text-red-500 mt-2">
|
||||
{errors.activeAnimeProfileId}
|
||||
</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 sm:pt-5">
|
||||
<label
|
||||
htmlFor="activeAnimeRootFolder"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
{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">
|
||||
<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"
|
||||
>
|
||||
<option value="">
|
||||
{isTesting
|
||||
? intl.formatMessage(messages.loadingrootfolders)
|
||||
: !isValidated
|
||||
? intl.formatMessage(messages.testFirstRootFolders)
|
||||
: intl.formatMessage(messages.selectRootFolder)}
|
||||
</option>
|
||||
{testResponse.rootFolders.length > 0 &&
|
||||
testResponse.rootFolders.map((folder) => (
|
||||
<option
|
||||
key={`loaded-profile-${folder.id}`}
|
||||
value={folder.path}
|
||||
>
|
||||
{folder.path}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</div>
|
||||
{errors.activeAnimeRootFolder &&
|
||||
touched.activeAnimeRootFolder && (
|
||||
<div className="text-red-500 mt-2">
|
||||
{errors.rootFolder}
|
||||
</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-200 sm:pt-5">
|
||||
<label
|
||||
htmlFor="is4k"
|
||||
|
||||
@@ -8,6 +8,7 @@ import LoginWithPlex from './LoginWithPlex';
|
||||
import SetupSteps from './SetupSteps';
|
||||
import axios from 'axios';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import Badge from '../Common/Badge';
|
||||
|
||||
const messages = defineMessages({
|
||||
finish: 'Finish Setup',
|
||||
@@ -16,6 +17,9 @@ const messages = defineMessages({
|
||||
loginwithplex: 'Login with Plex',
|
||||
configureplex: 'Configure Plex',
|
||||
configureservices: 'Configure Services',
|
||||
tip: 'Tip',
|
||||
syncingbackground:
|
||||
'Syncing will run in the background. You can continue the setup process in the meantime.',
|
||||
});
|
||||
|
||||
const Setup: React.FC = () => {
|
||||
@@ -85,6 +89,12 @@ const Setup: React.FC = () => {
|
||||
{currentStep === 2 && (
|
||||
<div>
|
||||
<SettingsPlex onComplete={() => setPlexSettingsComplete(true)} />
|
||||
<div className="mt-4 text-gray-500 text-sm">
|
||||
<span className="mr-2">
|
||||
<Badge>{intl.formatMessage(messages.tip)}</Badge>
|
||||
</span>
|
||||
{intl.formatMessage(messages.syncingbackground)}
|
||||
</div>
|
||||
<div className="mt-8 border-t border-gray-700 pt-5">
|
||||
<div className="flex justify-end">
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
|
||||
@@ -117,7 +117,7 @@ const Slider: React.FC<SliderProps> = ({
|
||||
to: {
|
||||
x: newX,
|
||||
},
|
||||
onFrame: (props) => {
|
||||
onFrame: (props: { x: number }) => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollLeft = props.x;
|
||||
}
|
||||
@@ -141,7 +141,7 @@ const Slider: React.FC<SliderProps> = ({
|
||||
to: {
|
||||
x: newX,
|
||||
},
|
||||
onFrame: (props) => {
|
||||
onFrame: (props: { x: number }) => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollLeft = props.x;
|
||||
}
|
||||
|
||||
@@ -10,17 +10,18 @@ import Link from 'next/link';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import RequestModal from '../RequestModal';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useIsTouch } from '../../hooks/useIsTouch';
|
||||
|
||||
const messages = defineMessages({
|
||||
movie: 'MOVIE',
|
||||
tvshow: 'SERIES',
|
||||
movie: 'Movie',
|
||||
tvshow: 'Series',
|
||||
});
|
||||
|
||||
interface TitleCardProps {
|
||||
id: number;
|
||||
image?: string;
|
||||
summary?: string;
|
||||
year: string;
|
||||
year?: string;
|
||||
title: string;
|
||||
userScore: number;
|
||||
mediaType: MediaType;
|
||||
@@ -38,6 +39,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
||||
mediaType,
|
||||
canExpand = false,
|
||||
}) => {
|
||||
const isTouch = useIsTouch();
|
||||
const intl = useIntl();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [currentStatus, setCurrentStatus] = useState(status);
|
||||
@@ -74,9 +76,13 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
||||
<div
|
||||
className="titleCard outline-none cursor-default"
|
||||
style={{
|
||||
backgroundImage: `url(//image.tmdb.org/t/p/w600_and_h900_bestv2${image})`,
|
||||
backgroundImage: `url(//image.tmdb.org/t/p/w300_and_h450_face${image})`,
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (!isTouch) {
|
||||
setShowDetail(true);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setShowDetail(true)}
|
||||
onMouseLeave={() => setShowDetail(false)}
|
||||
onClick={() => setShowDetail(true)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -93,7 +99,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
||||
mediaType === 'movie' ? 'bg-blue-500' : 'bg-purple-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center text-center text-xs text-white h-4 px-2 py-1 font-normal">
|
||||
<div className="flex items-center text-center text-xs text-white h-4 px-2 py-1 font-normal uppercase">
|
||||
{mediaType === 'movie'
|
||||
? intl.formatMessage(messages.movie)
|
||||
: intl.formatMessage(messages.tvshow)}
|
||||
@@ -146,158 +152,162 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
||||
|
||||
<Transition
|
||||
show={!image || showDetail || showRequestModal}
|
||||
enter="transition ease-in-out duration-300 transform opacity-0"
|
||||
enter="transition transform opacity-0"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition ease-in-out duration-300 transform opacity-100"
|
||||
leave="transition transform opacity-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div
|
||||
className="absolute w-full text-left top-0 right-0 left-0 bottom-0 rounded-lg overflow-hidden"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(180deg, rgba(45, 55, 72, 0.4) 0%, rgba(45, 55, 72, 0.9) 100%)',
|
||||
}}
|
||||
>
|
||||
<div className="absolute bottom-0 w-full left-0 right-0">
|
||||
<div className="px-2 text-white">
|
||||
<div className="text-sm">{year}</div>
|
||||
<Link href={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}>
|
||||
<a
|
||||
className="absolute w-full text-left top-0 right-0 left-0 bottom-0 rounded-lg overflow-hidden cursor-pointer"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(180deg, rgba(45, 55, 72, 0.4) 0%, rgba(45, 55, 72, 0.9) 100%)',
|
||||
}}
|
||||
>
|
||||
<div className="absolute bottom-0 w-full left-0 right-0">
|
||||
<div className="px-2 text-white">
|
||||
{year && <div className="text-sm">{year}</div>}
|
||||
|
||||
<h1 className="text-xl leading-tight whitespace-normal">
|
||||
{title}
|
||||
</h1>
|
||||
<div
|
||||
className="text-xs whitespace-normal"
|
||||
style={{
|
||||
WebkitLineClamp: 3,
|
||||
display: '-webkit-box',
|
||||
overflow: 'hidden',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{summary}
|
||||
<h1 className="text-xl leading-tight whitespace-normal">
|
||||
{title}
|
||||
</h1>
|
||||
<div
|
||||
className="text-xs whitespace-normal"
|
||||
style={{
|
||||
WebkitLineClamp: 3,
|
||||
display: '-webkit-box',
|
||||
overflow: 'hidden',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between left-0 bottom-0 right-0 top-0 px-2 py-2">
|
||||
<Link
|
||||
href={
|
||||
mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`
|
||||
}
|
||||
>
|
||||
<a className="cursor-pointer flex w-full h-7 text-center text-white bg-indigo-500 rounded-sm hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 transition ease-in-out duration-150">
|
||||
<svg
|
||||
className="w-4 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
{(!currentStatus ||
|
||||
currentStatus === MediaStatus.UNKNOWN) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowRequestModal(true);
|
||||
}}
|
||||
className="w-full h-7 text-center text-white bg-indigo-500 rounded-sm ml-2 hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 transition ease-in-out duration-150"
|
||||
>
|
||||
<svg
|
||||
className="w-4 mx-auto"
|
||||
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>
|
||||
</button>
|
||||
)}
|
||||
{currentStatus === MediaStatus.PENDING && (
|
||||
<button
|
||||
className="w-full h-7 text-center text-yellow-500 border border-yellow-500 rounded-sm ml-2 cursor-default"
|
||||
disabled
|
||||
>
|
||||
<svg
|
||||
className="w-4 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{currentStatus === MediaStatus.PROCESSING && (
|
||||
<button
|
||||
className="w-full h-7 text-center text-red-500 border border-red-500 rounded-sm ml-2 cursor-default"
|
||||
disabled
|
||||
>
|
||||
<svg
|
||||
className="w-4 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{(currentStatus === MediaStatus.AVAILABLE ||
|
||||
currentStatus === MediaStatus.PARTIALLY_AVAILABLE) && (
|
||||
<button
|
||||
className="w-full h-7 text-center text-green-400 border border-green-400 rounded-sm ml-2 cursor-default"
|
||||
disabled
|
||||
>
|
||||
<svg
|
||||
className="w-4 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between left-0 bottom-0 right-0 top-0 px-2 py-2">
|
||||
<Link
|
||||
href={
|
||||
mediaType === 'movie' ? '/movie/[movieId]' : '/tv/[tvId]'
|
||||
}
|
||||
as={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}
|
||||
>
|
||||
<a className="cursor-pointer flex w-full h-7 text-center text-white bg-indigo-500 rounded-sm hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 transition ease-in-out duration-150">
|
||||
<svg
|
||||
className="w-4 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
{(!currentStatus ||
|
||||
currentStatus === MediaStatus.UNKNOWN) && (
|
||||
<button
|
||||
onClick={() => setShowRequestModal(true)}
|
||||
className="w-full h-7 text-center text-white bg-indigo-500 rounded-sm ml-2 hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 transition ease-in-out duration-150"
|
||||
>
|
||||
<svg
|
||||
className="w-4 mx-auto"
|
||||
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>
|
||||
</button>
|
||||
)}
|
||||
{currentStatus === MediaStatus.PENDING && (
|
||||
<button
|
||||
className="w-full h-7 text-center text-yellow-500 border border-yellow-500 rounded-sm ml-2 cursor-default"
|
||||
disabled
|
||||
>
|
||||
<svg
|
||||
className="w-4 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{currentStatus === MediaStatus.PROCESSING && (
|
||||
<button
|
||||
className="w-full h-7 text-center text-red-500 border border-red-500 rounded-sm ml-2 cursor-default"
|
||||
disabled
|
||||
>
|
||||
<svg
|
||||
className="w-4 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{(currentStatus === MediaStatus.AVAILABLE ||
|
||||
currentStatus === MediaStatus.PARTIALLY_AVAILABLE) && (
|
||||
<button
|
||||
className="w-full h-7 text-center text-green-400 border border-green-400 rounded-sm ml-2 cursor-default"
|
||||
disabled
|
||||
>
|
||||
<svg
|
||||
className="w-4 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
66
src/components/TvDetails/TvCast/index.tsx
Normal file
66
src/components/TvDetails/TvCast/index.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useContext } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import type { TvDetails } from '../../../../server/models/Tv';
|
||||
import { LanguageContext } from '../../../context/LanguageContext';
|
||||
import Error from '../../../pages/_error';
|
||||
import Header from '../../Common/Header';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import PersonCard from '../../PersonCard';
|
||||
|
||||
const messages = defineMessages({
|
||||
fullseriescast: 'Full Series Cast',
|
||||
});
|
||||
|
||||
const TvCast: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error } = useSWR<TvDetails>(
|
||||
`/api/v1/tv/${router.query.tvId}?language=${locale}`
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Error statusCode={404} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
subtext={
|
||||
<Link href={`/tv/${data.id}`}>
|
||||
<a className="hover:underline">{data.name}</a>
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{intl.formatMessage(messages.fullseriescast)}
|
||||
</Header>
|
||||
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
|
||||
{data?.credits.cast.map((person) => {
|
||||
return (
|
||||
<li
|
||||
key={person.id}
|
||||
className="col-span-1 flex flex-col text-center items-center"
|
||||
>
|
||||
<PersonCard
|
||||
name={person.name}
|
||||
personId={person.id}
|
||||
subName={person.character}
|
||||
profilePath={person.profilePath}
|
||||
canExpand
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TvCast;
|
||||
@@ -28,6 +28,8 @@ import RTAudRotten from '../../assets/rt_aud_rotten.svg';
|
||||
import type { RTRating } from '../../../server/api/rottentomatoes';
|
||||
import Head from 'next/head';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb';
|
||||
import ExternalLinkBlock from '../ExternalLinkBlock';
|
||||
|
||||
const messages = defineMessages({
|
||||
userrating: 'User Rating',
|
||||
@@ -56,6 +58,9 @@ const messages = defineMessages({
|
||||
'This will remove all media data including all requests for this item. This action is irreversible. If this item exists in your Plex library, the media information will be recreated next sync.',
|
||||
approve: 'Approve',
|
||||
decline: 'Decline',
|
||||
showtype: 'Show Type',
|
||||
anime: 'Anime',
|
||||
network: 'Network',
|
||||
});
|
||||
|
||||
interface TvDetailsProps {
|
||||
@@ -133,6 +138,14 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const isComplete =
|
||||
data.seasons.filter((season) => season.seasonNumber !== 0).length <=
|
||||
(
|
||||
data.mediaInfo?.seasons.filter(
|
||||
(season) => season.status === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-cover bg-center -mx-4 -mt-2 px-4 sm:px-8 pt-4 "
|
||||
@@ -196,14 +209,14 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
)}
|
||||
</SlideOver>
|
||||
<div className="flex flex-col items-center md:flex-row md:items-end pt-4">
|
||||
<div className="mr-4 flex-shrink-0">
|
||||
<div className="md:mr-4 flex-shrink-0">
|
||||
<img
|
||||
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
|
||||
alt=""
|
||||
className="rounded md:rounded-lg shadow md:shadow-2xl w-32 md:w-52"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-white flex flex-col mr-4 mt-4 md:mt-0 text-center md:text-left">
|
||||
<div className="text-white flex flex-col md:mr-4 mt-4 md:mt-0 text-center md:text-left">
|
||||
<div className="mb-2">
|
||||
{data.mediaInfo?.status === MediaStatus.AVAILABLE && (
|
||||
<Badge badgeType="success">
|
||||
@@ -227,8 +240,12 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-4xl">
|
||||
{data.name}{' '}
|
||||
<span className="text-2xl">({data.firstAirDate.slice(0, 4)})</span>
|
||||
<span>{data.name}</span>
|
||||
{data.firstAirDate && (
|
||||
<span className="text-2xl ml-2">
|
||||
({data.firstAirDate.slice(0, 4)})
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<span className="text-xs md:text-base mt-1 md:mt-0">
|
||||
{data.genres.map((g) => g.name).join(', ')}
|
||||
@@ -258,93 +275,95 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
<FormattedMessage {...messages.request} />
|
||||
</Button>
|
||||
)}
|
||||
{data.mediaInfo && data.mediaInfo.status !== MediaStatus.UNKNOWN && (
|
||||
<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={
|
||||
<>
|
||||
{data.mediaInfo &&
|
||||
data.mediaInfo.status !== MediaStatus.UNKNOWN &&
|
||||
!isComplete && (
|
||||
<ButtonWithDropdown
|
||||
dropdownIcon={
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
className="w-5 h-5"
|
||||
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"
|
||||
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>
|
||||
<FormattedMessage {...messages.requestmore} />
|
||||
</>
|
||||
}
|
||||
onClick={() => setShowRequestModal(true)}
|
||||
>
|
||||
{hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
activeRequests &&
|
||||
activeRequests.length > 0 && (
|
||||
}
|
||||
text={
|
||||
<>
|
||||
<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"
|
||||
>
|
||||
<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 }}
|
||||
<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"
|
||||
/>
|
||||
</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>
|
||||
</svg>
|
||||
<FormattedMessage {...messages.requestmore} />
|
||||
</>
|
||||
)}
|
||||
</ButtonWithDropdown>
|
||||
)}
|
||||
}
|
||||
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>
|
||||
)}
|
||||
{hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<Button
|
||||
buttonType="default"
|
||||
className="ml-2"
|
||||
className="ml-2 first:ml-0"
|
||||
onClick={() => setShowManager(true)}
|
||||
>
|
||||
<svg
|
||||
@@ -387,34 +406,36 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
<div className="bg-gray-900 rounded-lg shadow border border-gray-800">
|
||||
{(data.voteCount > 0 || ratingData) && (
|
||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0 items-center justify-center">
|
||||
{ratingData?.criticsRating && (
|
||||
<>
|
||||
<span className="text-sm">
|
||||
{ratingData.criticsRating === 'Rotten' ? (
|
||||
<RTRotten className="w-6 mr-1" />
|
||||
) : (
|
||||
<RTFresh className="w-6 mr-1" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-gray-400 text-sm mr-4 last:mr-0">
|
||||
{ratingData.criticsScore}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{ratingData?.audienceRating && (
|
||||
<>
|
||||
<span className="text-sm">
|
||||
{ratingData.audienceRating === 'Spilled' ? (
|
||||
<RTAudRotten className="w-6 mr-1" />
|
||||
) : (
|
||||
<RTAudFresh className="w-6 mr-1" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-gray-400 text-sm mr-4 last:mr-0">
|
||||
{ratingData.audienceScore}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{ratingData?.criticsRating &&
|
||||
(ratingData?.criticsScore ?? 0) > 0 && (
|
||||
<>
|
||||
<span className="text-sm">
|
||||
{ratingData.criticsRating === 'Rotten' ? (
|
||||
<RTRotten className="w-6 mr-1" />
|
||||
) : (
|
||||
<RTFresh className="w-6 mr-1" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-gray-400 text-sm mr-4 last:mr-0">
|
||||
{ratingData.criticsScore}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{ratingData?.audienceRating &&
|
||||
(ratingData?.audienceScore ?? 0) > 0 && (
|
||||
<>
|
||||
<span className="text-sm">
|
||||
{ratingData.audienceRating === 'Spilled' ? (
|
||||
<RTAudRotten className="w-6 mr-1" />
|
||||
) : (
|
||||
<RTAudFresh className="w-6 mr-1" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-gray-400 text-sm mr-4 last:mr-0">
|
||||
{ratingData.audienceScore}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{data.voteCount > 0 && (
|
||||
<>
|
||||
<span className="text-sm">
|
||||
@@ -427,6 +448,18 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{data.keywords.some(
|
||||
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
) && (
|
||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
|
||||
<span className="text-sm">
|
||||
{intl.formatMessage(messages.showtype)}
|
||||
</span>
|
||||
<span className="flex-1 text-right text-gray-400 text-sm">
|
||||
{intl.formatMessage(messages.anime)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
|
||||
<span className="text-sm">
|
||||
<FormattedMessage {...messages.status} />
|
||||
@@ -435,14 +468,40 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
{data.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
|
||||
<span className="text-sm">
|
||||
<FormattedMessage {...messages.originallanguage} />
|
||||
</span>
|
||||
<span className="flex-1 text-right text-gray-400 text-sm">
|
||||
{data.originalLanguage}
|
||||
</span>
|
||||
</div>
|
||||
{data.spokenLanguages.some(
|
||||
(lng) => lng.iso_639_1 === data.originalLanguage
|
||||
) && (
|
||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
|
||||
<span className="text-sm">
|
||||
<FormattedMessage {...messages.originallanguage} />
|
||||
</span>
|
||||
<span className="flex-1 text-right text-gray-400 text-sm">
|
||||
{
|
||||
data.spokenLanguages.find(
|
||||
(lng) => lng.iso_639_1 === data.originalLanguage
|
||||
)?.name
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{data.networks.length > 0 && (
|
||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
|
||||
<span className="text-sm">
|
||||
<FormattedMessage {...messages.network} />
|
||||
</span>
|
||||
<span className="flex-1 text-right text-gray-400 text-sm">
|
||||
{data.networks.map((n) => n.name).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<ExternalLinkBlock
|
||||
mediaType="tv"
|
||||
tmdbId={data.id}
|
||||
imdbId={data.externalIds.imdbId}
|
||||
rtUrl={ratingData?.url}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -478,6 +537,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
items={data?.credits.cast.slice(0, 20).map((person) => (
|
||||
<PersonCard
|
||||
key={`cast-item-${person.id}`}
|
||||
personId={person.id}
|
||||
name={person.name}
|
||||
subName={person.character}
|
||||
profilePath={person.profilePath}
|
||||
|
||||
@@ -23,13 +23,12 @@ const messages = defineMessages({
|
||||
'Grants permission to manage Overseerr users. Users with this permission cannot modify users with Administrator privilege, or grant it.',
|
||||
settings: 'Manage Settings',
|
||||
settingsDescription:
|
||||
'Grants permission to modify all Overseerr settings. User must have this permission to be able to grant it to others.',
|
||||
'Grants permission to modify all Overseerr settings. A user must have this permission to grant it to others.',
|
||||
managerequests: 'Manage Requests',
|
||||
managerequestsDescription:
|
||||
'Grants permission to manage Overseerr requests. This includes approving and denying requests.',
|
||||
request: 'Request',
|
||||
requestDescription:
|
||||
'Grants permission to make requests for movies or tv shows.',
|
||||
requestDescription: 'Grants permission to request movies and series.',
|
||||
vote: 'Vote',
|
||||
voteDescription:
|
||||
'Grants permission to vote on requests (voting not yet implemented)',
|
||||
@@ -38,7 +37,7 @@ const messages = defineMessages({
|
||||
'Grants auto approval for any requests made by this user.',
|
||||
save: 'Save',
|
||||
saving: 'Saving...',
|
||||
usersaved: 'User succesfully saved',
|
||||
usersaved: 'User saved',
|
||||
userfail: 'Something went wrong saving the user.',
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import type { User } from '../../../server/entity/User';
|
||||
@@ -10,6 +10,11 @@ import { Permission } from '../../hooks/useUser';
|
||||
import { useRouter } from 'next/router';
|
||||
import Header from '../Common/Header';
|
||||
import Table from '../Common/Table';
|
||||
import Transition from '../Transition';
|
||||
import Modal from '../Common/Modal';
|
||||
import axios from 'axios';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
|
||||
const messages = defineMessages({
|
||||
userlist: 'User List',
|
||||
@@ -24,12 +29,46 @@ const messages = defineMessages({
|
||||
admin: 'Admin',
|
||||
user: 'User',
|
||||
plexuser: 'Plex User',
|
||||
deleteuser: 'Delete User',
|
||||
userdeleted: 'User deleted',
|
||||
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.',
|
||||
});
|
||||
|
||||
const UserList: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const router = useRouter();
|
||||
const { data, error } = useSWR<User[]>('/api/v1/user');
|
||||
const { addToast } = useToasts();
|
||||
const { data, error, revalidate } = useSWR<User[]>('/api/v1/user');
|
||||
const [isDeleting, setDeleting] = useState(false);
|
||||
const [deleteModal, setDeleteModal] = useState<{
|
||||
isOpen: boolean;
|
||||
user?: User;
|
||||
}>({
|
||||
isOpen: false,
|
||||
});
|
||||
|
||||
const deleteUser = async () => {
|
||||
setDeleting(true);
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/v1/user/${deleteModal.user?.id}`);
|
||||
|
||||
addToast(intl.formatMessage(messages.userdeleted), {
|
||||
autoDismiss: true,
|
||||
appearance: 'success',
|
||||
});
|
||||
setDeleteModal({ isOpen: false });
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.userdeleteerror), {
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
});
|
||||
} finally {
|
||||
revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
@@ -37,6 +76,46 @@ const UserList: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<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={deleteModal.isOpen}
|
||||
>
|
||||
<Modal
|
||||
onOk={() => deleteUser()}
|
||||
okText={
|
||||
isDeleting
|
||||
? intl.formatMessage(globalMessages.deleting)
|
||||
: intl.formatMessage(globalMessages.delete)
|
||||
}
|
||||
okDisabled={isDeleting}
|
||||
okButtonType="danger"
|
||||
onCancel={() => setDeleteModal({ isOpen: false })}
|
||||
title={intl.formatMessage(messages.deleteuser)}
|
||||
iconSvg={
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
{intl.formatMessage(messages.deleteconfirm)}
|
||||
</Modal>
|
||||
</Transition>
|
||||
<Header extraMargin={4}>{intl.formatMessage(messages.userlist)}</Header>
|
||||
<Table>
|
||||
<thead>
|
||||
@@ -104,7 +183,11 @@ const UserList: React.FC = () => {
|
||||
>
|
||||
{intl.formatMessage(messages.edit)}
|
||||
</Button>
|
||||
<Button buttonType="danger">
|
||||
<Button
|
||||
buttonType="danger"
|
||||
disabled={hasPermission(Permission.ADMIN, user.permissions)}
|
||||
onClick={() => setDeleteModal({ isOpen: true, user })}
|
||||
>
|
||||
{intl.formatMessage(messages.delete)}
|
||||
</Button>
|
||||
</Table.TD>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user