Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b111f1c34 | ||
|
|
a6392cb2db | ||
|
|
b9bedac7d7 | ||
|
|
1b3797cf6e | ||
|
|
340f1a2119 | ||
|
|
cc2b2bc7a8 | ||
|
|
d36c1d2929 | ||
|
|
5af06bd872 | ||
|
|
d437cc2539 | ||
|
|
30b20df37a | ||
|
|
af40212a73 | ||
|
|
488874fc17 | ||
|
|
2ded8f5484 | ||
|
|
50dc9341dd | ||
|
|
fcb0dcf5be | ||
|
|
33fe0bdd1e | ||
|
|
3f7ef7af97 | ||
|
|
91bfff71b7 | ||
|
|
dc7f959cb4 | ||
|
|
93b5ea20ca | ||
|
|
f284e4ab97 | ||
|
|
febf0677b8 | ||
|
|
7f330aff2e | ||
|
|
88a8c1aa59 | ||
|
|
68112faefb | ||
|
|
f2375c902b | ||
|
|
ed53810fb3 | ||
|
|
c9ffac33f7 | ||
|
|
6245be1e10 | ||
|
|
c760ceaa5f | ||
|
|
92732fcb42 | ||
|
|
d48a7ba518 | ||
|
|
d401e33249 | ||
|
|
b423dc167d | ||
|
|
f1cd0878a5 | ||
|
|
b1b367aac6 | ||
|
|
99d50004e5 | ||
|
|
c79dc9f70f | ||
|
|
3ec4a9c76e | ||
|
|
8c49309c35 | ||
|
|
78a8091bcd | ||
|
|
bba09d69c1 | ||
|
|
0828b008ba | ||
|
|
05d2dac3ab | ||
|
|
2b0b8e05d9 | ||
|
|
62b3dc5471 | ||
|
|
8d2968572a | ||
|
|
216447121b | ||
|
|
aeb7a48d72 | ||
|
|
ab20c21184 | ||
|
|
a35209c739 | ||
|
|
e402c42aaa | ||
|
|
6565c7dd9b | ||
|
|
dca363809d | ||
|
|
85aec4f892 | ||
|
|
6d55b50e5e | ||
|
|
f7dc6256ba | ||
|
|
0a6ef6cc81 | ||
|
|
b3b421a674 | ||
|
|
032c14a226 | ||
|
|
084a842a4f | ||
|
|
739f667b54 | ||
|
|
54e9071e90 | ||
|
|
d0ac74ea4b | ||
|
|
54e9514489 | ||
|
|
1e402f710b | ||
|
|
2f204b9952 | ||
|
|
3486d0bf55 | ||
|
|
492d8e3daa | ||
|
|
0b1c2b1745 | ||
|
|
c767f5254c | ||
|
|
82614ca441 | ||
|
|
5683f55ebf | ||
|
|
dce10f743f | ||
|
|
b7ba90d67b | ||
|
|
00ee535077 | ||
|
|
e3312cef33 | ||
|
|
2957e156a5 | ||
|
|
daf64532bf | ||
|
|
1286d6f0f9 | ||
|
|
8d8db6cf5d | ||
|
|
82d5d18ee7 | ||
|
|
c73cf7b19c | ||
|
|
4f36ca718f | ||
|
|
0edb1f452b | ||
|
|
63789918c5 | ||
|
|
a4dca2356b | ||
|
|
50ce198471 | ||
|
|
a20f395c94 | ||
|
|
cbfe9beb31 | ||
|
|
2333a81cfc | ||
|
|
6c30d62b14 | ||
|
|
41aa72535f | ||
|
|
afda9c7dc2 | ||
|
|
f1df1caea5 | ||
|
|
d23c120dc0 | ||
|
|
4c6009bc2c |
@@ -539,6 +539,87 @@
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "sootylunatic",
|
||||
"name": "sootylunatic",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/36486087?v=4",
|
||||
"profile": "https://github.com/sootylunatic",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "JoKerIsCraZy",
|
||||
"name": "JoKerIsCraZy",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/47474211?v=4",
|
||||
"profile": "https://github.com/JoKerIsCraZy",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "GoByeBye",
|
||||
"name": "Daddie0",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/33762262?v=4",
|
||||
"profile": "https://daddie.dev",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Simoneu01",
|
||||
"name": "Simone",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/43807696?v=4",
|
||||
"profile": "http://ungaro.me",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "adan89lion",
|
||||
"name": "Seohyun Joo",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/6585644?v=4",
|
||||
"profile": "https://github.com/adan89lion",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "ty4ko",
|
||||
"name": "Sergey",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/21213535?v=4",
|
||||
"profile": "https://github.com/ty4ko",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "skafte1990",
|
||||
"name": "Shaaft",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/31465453?v=4",
|
||||
"profile": "https://github.com/skafte1990",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "sr093906",
|
||||
"name": "sr093906",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/8369201?v=4",
|
||||
"profile": "https://github.com/sr093906",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Nackophilz",
|
||||
"name": "Nackophilz",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/61667226?v=4",
|
||||
"profile": "https://github.com/Nackophilz",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
}
|
||||
],
|
||||
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
||||
|
||||
12
.github/CODEOWNERS
vendored
12
.github/CODEOWNERS
vendored
@@ -1,12 +1,14 @@
|
||||
# Global code ownership
|
||||
* @sct
|
||||
* @sct
|
||||
|
||||
# Documentation
|
||||
docs/ @TheCatLady @samwiseg0
|
||||
/.all-contributorsrc @TheCatLady @samwiseg0 @danshilm
|
||||
/*.md @TheCatLady @samwiseg0 @danshilm
|
||||
/docs/ @TheCatLady @samwiseg0 @danshilm
|
||||
|
||||
# Snap-related files
|
||||
.github/workflows/snap.yaml @samwiseg0
|
||||
snap/ @samwiseg0
|
||||
/.github/workflows/snap.yaml @samwiseg0
|
||||
/snap/ @samwiseg0
|
||||
|
||||
# i18n locale files
|
||||
src/i18n/locale/ @sct @TheCatLady
|
||||
/src/i18n/locale/ @sct @TheCatLady
|
||||
|
||||
91
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
91
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
name: 🐛 Bug Report
|
||||
description: Report a problem
|
||||
labels: ['type:bug', 'awaiting-triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
|
||||
Please note that we use GitHub issues exclusively for bug reports and feature requests. For support requests, please use our other support channels to get help.
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Please provide a clear and concise description of the bug or issue.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version of Overseerr are you running? (You can find this in Settings → About → Version.)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro-steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Please tell us how we can reproduce the undesired behavior.
|
||||
placeholder: |
|
||||
1. Go to [...]
|
||||
2. Click on [...]
|
||||
3. Scroll down to [...]
|
||||
4. See error in [...]
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, please provide screenshots depicting the problem.
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: Please copy and paste any relevant log output. (This will be automatically formatted into code, so no need for backticks.)
|
||||
render: shell
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
options:
|
||||
- desktop
|
||||
- smartphone
|
||||
- tablet
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: device
|
||||
attributes:
|
||||
label: Device
|
||||
description: e.g., iPhone X, Surface Pro, Samsung Galaxy Tab
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: e.g., iOS 8.1, Windows 10, Android 11
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser
|
||||
description: e.g., Chrome, Safari, Edge, Firefox
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Please provide any additional information that may be relevant or helpful.
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Code of Conduct
|
||||
description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md)
|
||||
options:
|
||||
- label: I agree to follow Overseerr's Code of Conduct
|
||||
required: true
|
||||
45
.github/ISSUE_TEMPLATE/bug_report.md
vendored
45
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,45 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Submit a report to help us improve
|
||||
title: ''
|
||||
labels: 'awaiting-triage, type:bug'
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
#### Description
|
||||
|
||||
Please provide a clear and concise description of the bug or issue.
|
||||
|
||||
#### Version
|
||||
|
||||
What version of Overseerr are you running? (You can find this in Settings → About → Version.)
|
||||
|
||||
#### Steps to Reproduce
|
||||
|
||||
Please tell us how we can reproduce the undesired behavior.
|
||||
|
||||
1. Go to [...]
|
||||
2. Click on [...]
|
||||
3. Scroll down to [...]
|
||||
4. See error in [...]
|
||||
|
||||
#### Expected Behavior
|
||||
|
||||
Please provide a clear and concise description of what you expected to happen.
|
||||
|
||||
#### Screenshots
|
||||
|
||||
If applicable, please provide screenshots depicting the problem.
|
||||
|
||||
#### Device
|
||||
|
||||
What device were you using when you encountered this issue? Please provide this information to help us reproduce and investigate the bug.
|
||||
|
||||
- **Platform:** [e.g., desktop, smartphone, tablet]
|
||||
- **Device:** [e.g., iPhone X, Surface Pro, Samsung Galaxy Tab]
|
||||
- **OS:** [e.g., iOS 8.1, Windows 10, Android 11]
|
||||
- **Browser:** [e.g., Chrome, Safari, Edge, Firefox]
|
||||
|
||||
#### Additional Context
|
||||
|
||||
Please provide any additional information that may be relevant or helpful.
|
||||
6
.github/ISSUE_TEMPLATE/config.yml
vendored
6
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Support via Discord
|
||||
- name: 💬 Support via Discord
|
||||
url: https://discord.gg/overseerr
|
||||
about: Chat with users and devs on support and setup related topics.
|
||||
- name: Support via GitHub Discussions
|
||||
about: Chat with other users and the Overseerr dev team
|
||||
- name: 💬 Support via GitHub Discussions
|
||||
url: https://github.com/sct/overseerr/discussions
|
||||
about: Ask questions and discuss with other community members
|
||||
|
||||
37
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
Normal file
37
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: ✨ Feature Request
|
||||
description: Suggest an idea
|
||||
labels: ['type:enhancement', 'awaiting-triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this feature request!
|
||||
|
||||
Please note that we use GitHub issues exclusively for bug reports and feature requests. For support requests, please use our other support channels to get help.
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Is your feature request related to a problem? If so, please provide a clear and concise description of the problem; e.g., "I'm always frustrated when [...]."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: desired-behavior
|
||||
attributes:
|
||||
label: Desired Behavior
|
||||
description: Provide a clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Provide any additional information or screenshots that may be relevant or helpful.
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Code of Conduct
|
||||
description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md)
|
||||
options:
|
||||
- label: I agree to follow Overseerr's Code of Conduct
|
||||
required: true
|
||||
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,19 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: 'awaiting-triage, type:enhancement'
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
#### Description
|
||||
|
||||
Is your feature request related to a problem? If so, please provide a clear and concise description of the problem. E.g., "I'm always frustrated when [...]."
|
||||
|
||||
#### Desired Behavior
|
||||
|
||||
Provide a clear and concise description of what you want to happen.
|
||||
|
||||
#### Additional Context
|
||||
|
||||
Provide any additional information or screenshots that may be relevant or helpful.
|
||||
40
.github/stale.yml
vendored
40
.github/stale.yml
vendored
@@ -1,18 +1,44 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
# Configuration for probot-stale - https://github.com/probot/stale
|
||||
|
||||
# Number of days of inactivity before an Issue or Pull Request becomes stale
|
||||
daysUntilStale: 60
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
|
||||
# Number of days of inactivity before an Issue or Pull Request with the stale label is closed.
|
||||
# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
|
||||
daysUntilClose: 7
|
||||
# Issues with these labels will never be considered stale
|
||||
|
||||
# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable
|
||||
exemptLabels:
|
||||
- pinned
|
||||
- security
|
||||
- dependencies
|
||||
# Label to use when marking an issue as stale
|
||||
- never-stale
|
||||
- priority:high
|
||||
- priority:medium
|
||||
|
||||
# Label to use when marking as stale
|
||||
staleLabel: stale
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
|
||||
# Comment to post when marking 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
|
||||
|
||||
# Comment to post when removing the stale label.
|
||||
# unmarkComment: >
|
||||
# Your comment here.
|
||||
|
||||
# Comment to post when closing a stale Issue or Pull Request.
|
||||
# closeComment: >
|
||||
# Your comment here.
|
||||
|
||||
# Limit to only `issues` or `pulls`
|
||||
# only: issues
|
||||
|
||||
# Optionally, specify configuration settings that are specific to just 'issues' or 'pulls':
|
||||
pulls:
|
||||
markComment: >
|
||||
This pull request 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.
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
test:
|
||||
name: Lint & Test Build
|
||||
runs-on: ubuntu-20.04
|
||||
container: node:14.17-alpine
|
||||
container: node:14.18-alpine
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2.3.4
|
||||
|
||||
19
.github/workflows/invalid_template.yml
vendored
19
.github/workflows/invalid_template.yml
vendored
@@ -1,19 +0,0 @@
|
||||
name: 'Invalid Template'
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled, unlabeled, reopened]
|
||||
|
||||
jobs:
|
||||
support:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: dessant/support-requests@v2.0.1
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
support-label: 'invalid:template-incomplete'
|
||||
issue-comment: >
|
||||
:wave: @{issue-author}, please follow the template provided.
|
||||
close-issue: true
|
||||
lock-issue: true
|
||||
issue-lock-reason: 'resolved'
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
test:
|
||||
name: Lint & Test Build
|
||||
runs-on: ubuntu-20.04
|
||||
container: node:14.17-alpine
|
||||
container: node:14.18-alpine
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2.3.4
|
||||
|
||||
2
.github/workflows/snap.yaml
vendored
2
.github/workflows/snap.yaml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
name: Lint & Test Build
|
||||
needs: jobs
|
||||
runs-on: ubuntu-20.04
|
||||
container: node:14.17-alpine
|
||||
container: node:14.18-alpine
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2.3.4
|
||||
|
||||
96
CHANGELOG.md
96
CHANGELOG.md
@@ -1,3 +1,99 @@
|
||||
# [1.28.0](https://github.com/sct/overseerr/compare/v1.27.0...v1.28.0) (2022-01-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing route guards to issues pages ([#2235](https://github.com/sct/overseerr/issues/2235)) ([c79dc9f](https://github.com/sct/overseerr/commit/c79dc9f70f512dbec0e3460ee78dbc9feccfbbb1))
|
||||
* allow basic HTTP auth in hostname validation ([#2307](https://github.com/sct/overseerr/issues/2307)) ([d48a7ba](https://github.com/sct/overseerr/commit/d48a7ba518f9c79d70e499037cb730eb3efe2c08))
|
||||
* **docker:** explicitly install python3 ([#2273](https://github.com/sct/overseerr/issues/2273)) [skip ci] ([f1cd087](https://github.com/sct/overseerr/commit/f1cd0878a5c74bddc864f5f8ce9e2f041bdde5ec))
|
||||
* **email:** use decrypted private key ([#2232](https://github.com/sct/overseerr/issues/2232)) ([8d29685](https://github.com/sct/overseerr/commit/8d2968572a569ed77a4d7c14ae1dc69935fa847e))
|
||||
* **frontend:** more issues-related fixes ([#2234](https://github.com/sct/overseerr/issues/2234)) ([3ec4a9c](https://github.com/sct/overseerr/commit/3ec4a9c76e1f31bee5c8801b389721bf8e5884e0))
|
||||
* **frontend:** setup page backdrops ([#2251](https://github.com/sct/overseerr/issues/2251)) ([78a8091](https://github.com/sct/overseerr/commit/78a8091bcd29a7cf50cc7c493c28710389817adf))
|
||||
* **frontend:** use consistent formatting & strings ([#2231](https://github.com/sct/overseerr/issues/2231)) ([2164471](https://github.com/sct/overseerr/commit/216447121b686b6d01a31b95ec0c8eb005f6b103))
|
||||
* handle Plex library settings migration failure gracefully ([#2254](https://github.com/sct/overseerr/issues/2254)) ([ed53810](https://github.com/sct/overseerr/commit/ed53810fb33f70722361c67d176ff4edf531ba45))
|
||||
* **issues:** only allow edit of own comments & do not allow non-admin delete of issues with comments ([#2248](https://github.com/sct/overseerr/issues/2248)) ([bba09d6](https://github.com/sct/overseerr/commit/bba09d69c1bc55c2f35db5a7986e7c935cc9619c))
|
||||
* **lang:** add missing string ([#2370](https://github.com/sct/overseerr/issues/2370)) ([d36c1d2](https://github.com/sct/overseerr/commit/d36c1d29295020efb76bac21a443b6f9049802f3))
|
||||
* **lang:** string edits ([#2229](https://github.com/sct/overseerr/issues/2229)) ([ab20c21](https://github.com/sct/overseerr/commit/ab20c21184639e1c7725f7cae96249c6fa157351))
|
||||
* **lang:** translations update from Weblate ([#2212](https://github.com/sct/overseerr/issues/2212)) ([85aec4f](https://github.com/sct/overseerr/commit/85aec4f8925746ebae9bcc99d8480b78ccfd851e))
|
||||
* **logs:** handle unexpected log messages ([#2303](https://github.com/sct/overseerr/issues/2303)) ([f284e4a](https://github.com/sct/overseerr/commit/f284e4ab978e502d2cc08e76226a8ebac91bb48f))
|
||||
* **logs:** lazily parse log message label ([#2359](https://github.com/sct/overseerr/issues/2359)) ([5af06bd](https://github.com/sct/overseerr/commit/5af06bd87226fbc6176b0c5e362824793165a34e))
|
||||
* **notif:** correct issue notif action URLs ([#2333](https://github.com/sct/overseerr/issues/2333)) ([dc7f959](https://github.com/sct/overseerr/commit/dc7f959cb422a8d89bcebc78377f1513412e542c))
|
||||
* **notif:** only send MEDIA_AVAILABLE notifications for non-declined requests ([#2343](https://github.com/sct/overseerr/issues/2343)) ([fcb0dcf](https://github.com/sct/overseerr/commit/fcb0dcf5be64bf9ca814bfe119586908922099c5))
|
||||
* **requests:** do not fail request edits if acting user lacks Manage Users permission ([#2338](https://github.com/sct/overseerr/issues/2338)) ([91bfff7](https://github.com/sct/overseerr/commit/91bfff71b7c05c9b9aad2c95282533eefbb6b2e7))
|
||||
* secure session cookie ([#2308](https://github.com/sct/overseerr/issues/2308)) ([7f330af](https://github.com/sct/overseerr/commit/7f330aff2e1d3546e8dd1a3e4b037b9beb1cc7f0))
|
||||
* **servarr:** handle baseurl error when testing connection ([#2294](https://github.com/sct/overseerr/issues/2294)) ([93b5ea2](https://github.com/sct/overseerr/commit/93b5ea20ca590996f6dc90713a76800180d0621c))
|
||||
* **servarr:** handle servaarr server being unavailable when scanning downloads ([#2358](https://github.com/sct/overseerr/issues/2358)) ([488874f](https://github.com/sct/overseerr/commit/488874fc17e4e4719e90d383b83b1e1a5217213b))
|
||||
* sort collection parts by release date ([#2368](https://github.com/sct/overseerr/issues/2368)) ([1b3797c](https://github.com/sct/overseerr/commit/1b3797cf6e6ef6b3d8c81e644382f6e3f68cfaaa))
|
||||
* **ui:** request badge styling in request list ([#2302](https://github.com/sct/overseerr/issues/2302)) ([f2375c9](https://github.com/sct/overseerr/commit/f2375c902b79dcb1f349500862775ae57ea7d406))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add production countries to movie/TV detail pages ([#2170](https://github.com/sct/overseerr/issues/2170)) ([30b20df](https://github.com/sct/overseerr/commit/30b20df37a9604ba1c066f89e54a5482a09575ea))
|
||||
* add quotas, advanced options, and toggles to collection request modal ([#1742](https://github.com/sct/overseerr/issues/1742)) ([af40212](https://github.com/sct/overseerr/commit/af40212a738f8d6d9a5bf26dc20c0c87780d6020))
|
||||
* **frontend:** add Discovery+ to network slider ([#2345](https://github.com/sct/overseerr/issues/2345)) ([2ded8f5](https://github.com/sct/overseerr/commit/2ded8f5484168bd7b8f45124d9ebdd296a5708d5))
|
||||
* issues ([#2180](https://github.com/sct/overseerr/issues/2180)) ([e402c42](https://github.com/sct/overseerr/commit/e402c42aaa7d795cd724856a2e23615bb1a3695d))
|
||||
* **lang:** add Polish display language ([#2261](https://github.com/sct/overseerr/issues/2261)) ([c760cea](https://github.com/sct/overseerr/commit/c760ceaa5f36c77fa3ce320fae1b4597d2d8b976))
|
||||
* **lang:** translated using Weblate (Chinese (Traditional)) ([#2272](https://github.com/sct/overseerr/issues/2272)) ([d401e33](https://github.com/sct/overseerr/commit/d401e33249cbbca6e707479e5f0207e298ef3248))
|
||||
* **lang:** translations update from Hosted Weblate ([#2277](https://github.com/sct/overseerr/issues/2277)) ([92732fc](https://github.com/sct/overseerr/commit/92732fcb42c56242d16daab00e2d38740b92dea0))
|
||||
* **lang:** translations update from Hosted Weblate ([#2315](https://github.com/sct/overseerr/issues/2315)) ([6245be1](https://github.com/sct/overseerr/commit/6245be1e10dda67c869b59522c1290e7c100145f))
|
||||
* **lang:** translations update from Hosted Weblate ([#2320](https://github.com/sct/overseerr/issues/2320)) ([68112fa](https://github.com/sct/overseerr/commit/68112faefbd64d5c71d3eff21620767f88ccfc34))
|
||||
* **lang:** translations update from Hosted Weblate ([#2325](https://github.com/sct/overseerr/issues/2325)) ([febf067](https://github.com/sct/overseerr/commit/febf0677b880d2fed2822ce510db7cbb0826a920))
|
||||
* **lang:** translations update from Hosted Weblate ([#2336](https://github.com/sct/overseerr/issues/2336)) ([3f7ef7a](https://github.com/sct/overseerr/commit/3f7ef7af97a807ef38041f4f2642b565aa33d066))
|
||||
* **lang:** translations update from Hosted Weblate ([#2341](https://github.com/sct/overseerr/issues/2341)) ([33fe0bd](https://github.com/sct/overseerr/commit/33fe0bdd1e00da40e85b4e4b4780134b31a105d2))
|
||||
* **lang:** translations update from Hosted Weblate ([#2346](https://github.com/sct/overseerr/issues/2346)) ([50dc934](https://github.com/sct/overseerr/commit/50dc9341dd98cb2d8ef3ef6471882a5a9b060afa))
|
||||
* **lang:** translations update from Hosted Weblate ([#2364](https://github.com/sct/overseerr/issues/2364)) ([d437cc2](https://github.com/sct/overseerr/commit/d437cc25392e9c0881888371ffabc82892a1b15c))
|
||||
* **lang:** translations update from Hosted Weblate ([#2366](https://github.com/sct/overseerr/issues/2366)) ([cc2b2bc](https://github.com/sct/overseerr/commit/cc2b2bc7a8ecd89e1feb38a907596b16df9bf0fc))
|
||||
* **lang:** translations update from Hosted Weblate ([#2374](https://github.com/sct/overseerr/issues/2374)) ([b9bedac](https://github.com/sct/overseerr/commit/b9bedac7d7ba85223ecf1d9b93b96e2a490d571a))
|
||||
* **lang:** translations update from Weblate ([#2226](https://github.com/sct/overseerr/issues/2226)) ([62b3dc5](https://github.com/sct/overseerr/commit/62b3dc5471c28f4d0e4399cb3bc8bfab94cff5ea))
|
||||
* **lang:** translations update from Weblate ([#2241](https://github.com/sct/overseerr/issues/2241)) ([2b0b8e0](https://github.com/sct/overseerr/commit/2b0b8e05d9c95ff9218cea858a920a2815871186))
|
||||
* **lang:** translations update from Weblate ([#2244](https://github.com/sct/overseerr/issues/2244)) ([0828b00](https://github.com/sct/overseerr/commit/0828b008badc8b512316799a6787bb7c403658d5))
|
||||
* **lang:** translations update from Weblate ([#2247](https://github.com/sct/overseerr/issues/2247)) ([8c49309](https://github.com/sct/overseerr/commit/8c49309c35c31f7bcd0b84b0a307febc16842f68))
|
||||
* **lang:** translations update from Weblate ([#2252](https://github.com/sct/overseerr/issues/2252)) ([99d5000](https://github.com/sct/overseerr/commit/99d50004e58f6b4594df0a171f6bc668635ec50c))
|
||||
* **lang:** translations update from Weblate ([#2265](https://github.com/sct/overseerr/issues/2265)) ([b1b367a](https://github.com/sct/overseerr/commit/b1b367aac625ed3eb865832c94c2352e5a5c40f5))
|
||||
* **notif:** 4K media notifications ([#2324](https://github.com/sct/overseerr/issues/2324)) ([88a8c1a](https://github.com/sct/overseerr/commit/88a8c1aa596e1113d6da52e5e8cbe443abc6384f))
|
||||
* **notif:** add Pushbullet and Pushover agents to user notification settings ([#1740](https://github.com/sct/overseerr/issues/1740)) ([aeb7a48](https://github.com/sct/overseerr/commit/aeb7a48d72cec3fa2b857030aad3eaa0a457a896))
|
||||
* **notif:** issue notifications ([#2242](https://github.com/sct/overseerr/issues/2242)) ([c9ffac3](https://github.com/sct/overseerr/commit/c9ffac33f7c04d926f8c45295703689d42fe87af))
|
||||
* **search:** close search bar when hitting return ([#2260](https://github.com/sct/overseerr/issues/2260)) ([b423dc1](https://github.com/sct/overseerr/commit/b423dc167d12f0ba49f902876bceb2e876e35f58))
|
||||
* **ui:** allow admins to edit & approve request from advanced request modal ([#2067](https://github.com/sct/overseerr/issues/2067)) ([340f1a2](https://github.com/sct/overseerr/commit/340f1a211952bd2e8f40f0ea4622b52dbe934e85))
|
||||
|
||||
# [1.27.0](https://github.com/sct/overseerr/compare/v1.26.1...v1.27.0) (2021-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** return queried user's requests instead of own requests ([#2174](https://github.com/sct/overseerr/issues/2174)) ([0edb1f4](https://github.com/sct/overseerr/commit/0edb1f452b6ff4a49ae2bde15f7273769788cf4f))
|
||||
* **api:** use query builder for user requests endpoint ([#2119](https://github.com/sct/overseerr/issues/2119)) ([a20f395](https://github.com/sct/overseerr/commit/a20f395c94c97dd7ddbc25590f15def2c9bf13c9))
|
||||
* apply request overrides iff override & selected servers match ([#2164](https://github.com/sct/overseerr/issues/2164)) ([50ce198](https://github.com/sct/overseerr/commit/50ce198471b1a3777a183d68904bbfb39ebd4523))
|
||||
* **email:** do not attempt to display logo if app URL not configured ([#2125](https://github.com/sct/overseerr/issues/2125)) ([b3b421a](https://github.com/sct/overseerr/commit/b3b421a67408a4a48d23c15341fcdf7aaf19b25a))
|
||||
* **frontend:** notification type validation ([#2207](https://github.com/sct/overseerr/issues/2207)) ([2f204b9](https://github.com/sct/overseerr/commit/2f204b995269a53ae36f2a8733f27ae6ab70da5a))
|
||||
* **scripts:** update migration scripts ([#2208](https://github.com/sct/overseerr/issues/2208)) [skip ci] ([d0ac74e](https://github.com/sct/overseerr/commit/d0ac74ea4bbfcf3d25d30cbd422d9df1c1259a18))
|
||||
* **ui:** refinements for 'About' page ([#2173](https://github.com/sct/overseerr/issues/2173)) ([084a842](https://github.com/sct/overseerr/commit/084a842a4f9b6caaed22edbe77bc9e414bc1f387))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* display release dates for theatrical, digital, and physical release types ([#1492](https://github.com/sct/overseerr/issues/1492)) ([a4dca23](https://github.com/sct/overseerr/commit/a4dca2356b7605026f7bc45b691496e765c3328c))
|
||||
* dynamically fetch login screen backdrop images ([#2206](https://github.com/sct/overseerr/issues/2206)) ([3486d0b](https://github.com/sct/overseerr/commit/3486d0bf5520cbdff60bd8fd023caed76c452973))
|
||||
* **frontend:** add Hulu to network slider ([#2204](https://github.com/sct/overseerr/issues/2204)) ([1e402f7](https://github.com/sct/overseerr/commit/1e402f710b53c11855aab0abdb4b12c51c30b022))
|
||||
* **jobs:** allow modifying job schedules ([#1440](https://github.com/sct/overseerr/issues/1440)) ([82614ca](https://github.com/sct/overseerr/commit/82614ca4410782a12d65b4c0a6526ff064be1241))
|
||||
* **lang:** add Czech and Danish display languages ([#2176](https://github.com/sct/overseerr/issues/2176)) ([8d8db6c](https://github.com/sct/overseerr/commit/8d8db6cf5d98d4e498a31db339d02f8a98057c8d))
|
||||
* **lang:** translations update from Weblate ([#2101](https://github.com/sct/overseerr/issues/2101)) ([c73cf7b](https://github.com/sct/overseerr/commit/c73cf7b19cbc19e97a777c0facb9264fb0113093))
|
||||
* **lang:** translations update from Weblate ([#2179](https://github.com/sct/overseerr/issues/2179)) ([e3312ce](https://github.com/sct/overseerr/commit/e3312cef33821c8cb76a4a63bd565c78d67b3e0b))
|
||||
* **lang:** translations update from Weblate ([#2185](https://github.com/sct/overseerr/issues/2185)) ([dce10f7](https://github.com/sct/overseerr/commit/dce10f743f52cb04036e2cdaee280e26a81b253b))
|
||||
* **lang:** translations update from Weblate ([#2202](https://github.com/sct/overseerr/issues/2202)) ([492d8e3](https://github.com/sct/overseerr/commit/492d8e3daa5fb99aa9df2a18978085d5ddd581e7))
|
||||
* **lang:** translations update from Weblate ([#2210](https://github.com/sct/overseerr/issues/2210)) ([0a6ef6c](https://github.com/sct/overseerr/commit/0a6ef6cc81376f7a02f1483109be7ae4ab851c48))
|
||||
* **plex-scan:** plex scanner improvements ([#2105](https://github.com/sct/overseerr/issues/2105)) ([afda9c7](https://github.com/sct/overseerr/commit/afda9c7dc222137b0e6654a6beb4737cf2c1752e))
|
||||
* **servarr:** auto fill base url when testing service if missing ([#1995](https://github.com/sct/overseerr/issues/1995)) ([739f667](https://github.com/sct/overseerr/commit/739f667b54d8dec258b74d0cd8fd8b3b88dcf8d5))
|
||||
* **ui:** link processing/requested status badges to service URL ([#1761](https://github.com/sct/overseerr/issues/1761)) ([032c14a](https://github.com/sct/overseerr/commit/032c14a22680f62f8106943297b081b68645ce61))
|
||||
|
||||
## [1.26.1](https://github.com/sct/overseerr/compare/v1.26.0...v1.26.1) (2021-09-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **rt-api:** correctly format movie urls ([4c6009b](https://github.com/sct/overseerr/commit/4c6009bc2c3ff5f657a806363e3bdf7cd83d4261))
|
||||
|
||||
# [1.26.0](https://github.com/sct/overseerr/compare/v1.25.0...v1.26.0) (2021-09-19)
|
||||
|
||||
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -1,4 +1,4 @@
|
||||
FROM node:14.17-alpine AS BUILD_IMAGE
|
||||
FROM node:14.18-alpine AS BUILD_IMAGE
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -7,8 +7,10 @@ ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
|
||||
|
||||
RUN \
|
||||
case "${TARGETPLATFORM}" in \
|
||||
'linux/arm64') apk add --no-cache python make g++ ;; \
|
||||
'linux/arm/v7') apk add --no-cache python make g++ ;; \
|
||||
'linux/arm64' | 'linux/arm/v7') \
|
||||
apk add --no-cache python3 make g++ && \
|
||||
ln -s /usr/bin/python3 /usr/bin/python \
|
||||
;; \
|
||||
esac
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
@@ -31,7 +33,7 @@ RUN touch config/DOCKER
|
||||
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
||||
|
||||
|
||||
FROM node:14.17-alpine
|
||||
FROM node:14.18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:14.17-alpine
|
||||
FROM node:14.18-alpine
|
||||
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
13
README.md
13
README.md
@@ -12,7 +12,7 @@
|
||||
<a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a>
|
||||
<a href="https://github.com/sct/overseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr"></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-58-orange.svg"/></a>
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-67-orange.svg"/></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
</p>
|
||||
|
||||
@@ -149,6 +149,17 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/tangentThought"><img src="https://avatars.githubusercontent.com/u/25516090?v=4?s=100" width="100px;" alt=""/><br /><sub><b>tangentThought</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=tangentThought" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/nicospz"><img src="https://avatars.githubusercontent.com/u/31373060?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Nicolás Espinoza</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=nicospz" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/sootylunatic"><img src="https://avatars.githubusercontent.com/u/36486087?v=4?s=100" width="100px;" alt=""/><br /><sub><b>sootylunatic</b></sub></a><br /><a href="#translation-sootylunatic" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/JoKerIsCraZy"><img src="https://avatars.githubusercontent.com/u/47474211?v=4?s=100" width="100px;" alt=""/><br /><sub><b>JoKerIsCraZy</b></sub></a><br /><a href="#translation-JoKerIsCraZy" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://daddie.dev"><img src="https://avatars.githubusercontent.com/u/33762262?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Daddie0</b></sub></a><br /><a href="#translation-GoByeBye" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="http://ungaro.me"><img src="https://avatars.githubusercontent.com/u/43807696?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simone</b></sub></a><br /><a href="#translation-Simoneu01" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/adan89lion"><img src="https://avatars.githubusercontent.com/u/6585644?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Seohyun Joo</b></sub></a><br /><a href="#translation-adan89lion" title="Translation">🌍</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/ty4ko"><img src="https://avatars.githubusercontent.com/u/21213535?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sergey</b></sub></a><br /><a href="#translation-ty4ko" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/skafte1990"><img src="https://avatars.githubusercontent.com/u/31465453?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Shaaft</b></sub></a><br /><a href="#translation-skafte1990" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/sr093906"><img src="https://avatars.githubusercontent.com/u/8369201?v=4?s=100" width="100px;" alt=""/><br /><sub><b>sr093906</b></sub></a><br /><a href="#translation-sr093906" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/Nackophilz"><img src="https://avatars.githubusercontent.com/u/61667226?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Nackophilz</b></sub></a><br /><a href="#translation-Nackophilz" title="Translation">🌍</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -145,8 +145,7 @@ location ^~ /overseerr {
|
||||
sub_filter '/android-' '/$app/android-';
|
||||
sub_filter '/apple-' '/$app/apple-';
|
||||
sub_filter '/favicon' '/$app/favicon';
|
||||
sub_filter '/logo_full.svg' '/$app/logo_full.svg';
|
||||
sub_filter '/logo_stacked.svg' '/$app/logo_stacked.svg';
|
||||
sub_filter '/logo_' '/$app/logo_';
|
||||
sub_filter '/site.webmanifest' '/$app/site.webmanifest';
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
# Third-Party Integrations
|
||||
|
||||
{% hint style="warning" %}
|
||||
We do not officially support these third-party integrations. If you run into any issues, please seek help on the appropriate support channels for the integration itself!
|
||||
**We do not officially support these third-party integrations.** If you run into any issues, please seek help on the appropriate support channels for the integration itself!
|
||||
{% endhint %}
|
||||
|
||||
- [Organizr](https://organizr.app/), a HTPC/homelab services organizer
|
||||
- [Heimdall](https://github.com/linuxserver/Heimdall), an application dashboard and launcher
|
||||
- [LunaSea](https://docs.lunasea.app/modules/overseerr), a self-hosted controller for mobile and macOS
|
||||
- [Requestrr](https://github.com/darkalfx/requestrr/wiki/Configuring-Overseerr), a Discord chatbot
|
||||
- [Doplarr](https://github.com/kiranshila/Doplarr), a Discord request bot
|
||||
- [Overseerr Assistant](https://github.com/RemiRigal/Overseerr-Assistant), a browser extension for requesting directly from TMDb and IMDb
|
||||
- [ha-overseerr](https://github.com/vaparr/ha-overseerr), a custom Home Assistant component
|
||||
- [OverCLIrr](https://github.com/WillFantom/OverCLIrr), a command-line tool
|
||||
- [Overseerr Exporter](https://github.com/WillFantom/overseerr-exporter), a Prometheus exporter
|
||||
|
||||
@@ -10,8 +10,18 @@ After running Overseerr for the first time, configure it by visiting the web UI
|
||||
|
||||
## Docker
|
||||
|
||||
{% hint style="warning" %}
|
||||
Be sure to replace `/path/to/appdata/config` in the below examples with a valid host directory path. If this volume mount is not configured correctly, your Overseerr settings/data will not be persisted when the container is recreated (e.g., when updating the image or rebooting your machine).
|
||||
|
||||
The `TZ` environment variable value should also be set to the [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) of your time zone!
|
||||
{% endhint %}
|
||||
|
||||
{% tabs %}
|
||||
{% tab title="Basic" %}
|
||||
{% tab title="Docker CLI" %}
|
||||
|
||||
For details on the Docker CLI, please [review the official `docker run` documentation](https://docs.docker.com/engine/reference/run/).
|
||||
|
||||
**Installation:**
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
@@ -24,11 +34,41 @@ docker run -d \
|
||||
sctx/overseerr
|
||||
```
|
||||
|
||||
To run the container as a specific user/group, you may optionally add `--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ]` to the above command.
|
||||
|
||||
**Updating:**
|
||||
|
||||
Stop and remove the existing container:
|
||||
|
||||
```bash
|
||||
docker stop overseerr && docker rm overseerr
|
||||
```
|
||||
|
||||
Pull the latest image:
|
||||
|
||||
```bash
|
||||
docker pull sctx/overseerr
|
||||
```
|
||||
|
||||
Finally, run the container with the same parameters originally used to create the container:
|
||||
|
||||
```bash
|
||||
docker run -d ...
|
||||
```
|
||||
|
||||
{% hint style="info" %}
|
||||
You may alternatively use a third-party updating mechanism, such as [Watchtower](https://github.com/containrrr/watchtower) or [Ouroboros](https://github.com/pyouroboros/ouroboros), to keep Overseerr up-to-date automatically.
|
||||
{% endhint %}
|
||||
|
||||
{% endtab %}
|
||||
|
||||
{% tab title="Compose" %}
|
||||
{% tab title="Docker Compose" %}
|
||||
|
||||
**docker-compose.yml:**
|
||||
For details on how to use Docker Compose, please [review the official Compose documentation](https://docs.docker.com/compose/reference/).
|
||||
|
||||
**Installation:**
|
||||
|
||||
Define the `overseerr` service in your `docker-compose.yml` as follows:
|
||||
|
||||
```yaml
|
||||
---
|
||||
@@ -48,47 +88,29 @@ services:
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
{% endtab %}
|
||||
|
||||
{% tab title="UID/GID" %}
|
||||
|
||||
```text
|
||||
docker run -d \
|
||||
--name overseerr \
|
||||
--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ] \
|
||||
-e LOG_LEVEL=debug \
|
||||
-e TZ=Asia/Tokyo \
|
||||
-p 5055:5055 \
|
||||
-v /path/to/appdata/config:/app/config \
|
||||
--restart unless-stopped \
|
||||
sctx/overseerr
|
||||
```
|
||||
|
||||
{% endtab %}
|
||||
|
||||
{% tab title="Manual Update" %}
|
||||
Then, start all services defined in the your Compose file:
|
||||
|
||||
```bash
|
||||
# Stop the Overseerr container
|
||||
docker stop overseerr
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
# Remove the Overseerr container
|
||||
docker rm overseerr
|
||||
**Updating:**
|
||||
|
||||
# Pull the latest update
|
||||
docker pull sctx/overseerr
|
||||
Pull the latest image:
|
||||
|
||||
# Run the Overseerr container with the same parameters as before
|
||||
docker run -d ...
|
||||
```bash
|
||||
docker-compose pull overseerr
|
||||
```
|
||||
|
||||
Then, restart all services defined in the Compose file:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
{% endtab %}
|
||||
{% endtabs %}
|
||||
|
||||
{% hint style="info" %}
|
||||
Use a 3rd party updating mechanism such as [Watchtower](https://github.com/containrrr/watchtower) or [Ouroboros](https://github.com/pyouroboros/ouroboros) to keep Overseerr up-to-date automatically.
|
||||
{% endhint %}
|
||||
|
||||
## Unraid
|
||||
|
||||
1. Ensure you have the **Community Applications** plugin installed.
|
||||
@@ -144,29 +166,24 @@ The [Overseerr snap](https://snapcraft.io/overseerr) is the only officially supp
|
||||
Currently, the listening port cannot be changed, so port `5055` will need to be available on your host. To install `snapd`, please refer to the [Snapcraft documentation](https://snapcraft.io/docs/installing-snapd).
|
||||
{% endhint %}
|
||||
|
||||
**To install:**
|
||||
**Installation:**
|
||||
|
||||
```
|
||||
sudo snap install overseerr
|
||||
```
|
||||
|
||||
{% hint style="danger" %}
|
||||
To install the development build, add the `--edge` argument to the above command (i.e., `sudo snap install overseerr --edge`). However, note that this version can break any moment. Be prepared to troubleshoot any issues that arise!
|
||||
{% endhint %}
|
||||
|
||||
**Updating:**
|
||||
|
||||
Snap will keep Overseerr up-to-date automatically. You can force a refresh by using the following command.
|
||||
|
||||
```
|
||||
```bash
|
||||
sudo snap refresh
|
||||
```
|
||||
|
||||
**To install the development build:**
|
||||
|
||||
```
|
||||
sudo snap install overseerr --edge
|
||||
```
|
||||
|
||||
{% hint style="danger" %}
|
||||
This version can break any moment. Be prepared to troubleshoot any issues that arise!
|
||||
{% endhint %}
|
||||
|
||||
## Third-Party
|
||||
|
||||
{% tabs %}
|
||||
|
||||
@@ -20,6 +20,12 @@ A more advanced, user-friendly, and secure (if using SSL) method is to set up a
|
||||
|
||||
The most secure method (but also the most inconvenient method) is to set up a VPN tunnel to your home server. You would then be able to access Overseerr as if you were on your local network, via `http://LOCAL-IP-ADDRESS:5055`.
|
||||
|
||||
### Are there mobile apps for Overseerr?
|
||||
|
||||
Since Overseerr has an almost native app experience when installed as a Progressive Web App (PWA), there are no plans to develop mobile apps for Overseerr.
|
||||
|
||||
Out of the box, Overseerr already fulfills most of the [PWA install criteria](https://web.dev/install-criteria/). You simply need to make sure that your Overseerr instance is being served over HTTPS (e.g., via a [reverse proxy](../extending-overseerr/reverse-proxy.md)).
|
||||
|
||||
### Overseerr is amazing! But it is not translated in my language yet! Can I help with translations?
|
||||
|
||||
You sure can! We are using [Weblate](https://hosted.weblate.org/engage/overseerr/) for translations. If your language is not listed, please [open a feature request on GitHub](https://github.com/sct/overseerr/issues/new/choose).
|
||||
@@ -119,3 +125,9 @@ Language profile support for Sonarr was added in [v1.20.0](https://github.com/sc
|
||||
### I am getting "Username and Password not accepted" when attempting to send email notifications via Gmail!
|
||||
|
||||
If you have 2-Step Verification enabled on your account, you will need to create an [app password](https://support.google.com/mail/answer/185833).
|
||||
|
||||
### The logo image in email notifications is broken!
|
||||
|
||||
This may be an issue with how you are proxying your Overseerr instance. A good first troubleshooting step is to verify that the [`Content-Security-Policy` HTTP header](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) being set by your proxy (if any) is configured appropriately to allow external embedding of the image.
|
||||
|
||||
For Gmail users, another possible issue is that Google's image URL proxy is being blocked from fetching the image. If using Cloudflare, overzealous firewall rules could be the culprit.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Pushbullet
|
||||
|
||||
{% hint style="info" %}
|
||||
Users can optionally configure personal notifications in their user settings.
|
||||
|
||||
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
|
||||
{% endhint %}
|
||||
|
||||
## Configuration
|
||||
|
||||
### Access Token
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Pushover
|
||||
|
||||
{% hint style="info" %}
|
||||
Users can optionally configure personal notifications in their user settings.
|
||||
|
||||
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
|
||||
{% endhint %}
|
||||
|
||||
## Configuration
|
||||
|
||||
### Application/API Token
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Telegram
|
||||
|
||||
{% hint style="info" %}
|
||||
Users can optionally configure their own notifications in their user settings.
|
||||
Users can optionally configure personal notifications in their user settings.
|
||||
|
||||
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
|
||||
{% endhint %}
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -24,23 +24,28 @@ Customize the JSON payload to suit your needs. Overseerr provides several [templ
|
||||
|
||||
### General
|
||||
|
||||
- `{{notification_type}}` The type of notification. (Ex. `MEDIA_PENDING` or `MEDIA_APPROVED`)
|
||||
- `{{subject}}` The notification subject message. (For request notifications, this is the media title)
|
||||
- `{{message}}` Notification message body. (For request notifications, this is the media's overview/synopsis)
|
||||
- `{{image}}` Associated image with the request. (For request notifications, this is the media's poster)
|
||||
| Variable | Value |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `{{notification_type}}` | The type of notification (e.g. `MEDIA_PENDING` or `ISSUE_COMMENT`) |
|
||||
| `{{event}}` | A friendly description of the notification event |
|
||||
| `{{subject}}` | The notification subject (typically the media title) |
|
||||
| `{{message}}` | The notification message body (the media overview/synopsis for request notifications; the issue description for issue notificatons) |
|
||||
| `{{image}}` | The notification image (typically the media poster) |
|
||||
|
||||
### User
|
||||
### Notify User
|
||||
|
||||
These variables are for the target recipient of the notification.
|
||||
|
||||
- `{{notifyuser_username}}` Target user's username.
|
||||
- `{{notifyuser_email}}` Target user's email address.
|
||||
- `{{notifyuser_avatar}}` Target user's avatar URL.
|
||||
- `{{notifyuser_settings_discordId}}` Target user's Discord ID (if one is set).
|
||||
- `{{notifyuser_settings_telegramChatId}}` Target user's Telegram Chat ID (if one is set).
|
||||
| Variable | Value |
|
||||
| ---------------------------------------- | ------------------------------------------------------------- |
|
||||
| `{{notifyuser_username}}` | The target notification recipient's username |
|
||||
| `{{notifyuser_email}}` | The target notification recipient's email address |
|
||||
| `{{notifyuser_avatar}}` | The target notification recipient's avatar URL |
|
||||
| `{{notifyuser_settings_discordId}}` | The target notification recipient's Discord ID (if set) |
|
||||
| `{{notifyuser_settings_telegramChatId}}` | The target notification recipient's Telegram Chat ID (if set) |
|
||||
|
||||
{% hint style="info" %}
|
||||
The `notifyuser` variables are not set for the following notification types, as they are intended for application administrators rather than end users:
|
||||
The `notifyuser` variables are not defined for the following request notification types, as they are intended for application administrators rather than end users:
|
||||
|
||||
- Media Requested
|
||||
- Media Automatically Approved
|
||||
@@ -59,28 +64,69 @@ If you would like to use the requesting user's information in your webhook, plea
|
||||
|
||||
The following variables must be used as a key in the JSON payload (e.g., `"{{extra}}": []`).
|
||||
|
||||
- `{{request}}` This object will be `null` if there is no relevant request object for the notification.
|
||||
- `{{media}}` This object will be `null` if there is no relevant media object for the notification.
|
||||
- `{{extra}}` This object will contain the "extra" array of additional data for certain notifications.
|
||||
| Variable | Value |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `{{media}}` | The relevant media object |
|
||||
| `{{request}}` | The relevant request object |
|
||||
| `{{issue}}` | The relevant issue object |
|
||||
| `{{comment}}` | The relevant issue comment object |
|
||||
| `{{extra}}` | The "extra" array of additional data for certain notifications (e.g., season/episode numbers for series-related notifications) |
|
||||
|
||||
#### Media
|
||||
|
||||
These `{{media}}` special variables are only included in media-related notifications, such as requests.
|
||||
The `{{media}}` will be `null` if there is no relevant media object for the notification.
|
||||
|
||||
- `{{media_type}}` Media type (`movie` or `tv`).
|
||||
- `{{media_tmdbid}}` Media's TMDb ID.
|
||||
- `{{media_imdbid}}` Media's IMDb ID.
|
||||
- `{{media_tvdbid}}` Media's TVDB ID.
|
||||
- `{{media_status}}` Media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`).
|
||||
- `{{media_status4k}}` Media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`)
|
||||
These following special variables are only included in media-related notifications, such as requests.
|
||||
|
||||
| Variable | Value |
|
||||
| -------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `{{media_type}}` | The media type (`movie` or `tv`) |
|
||||
| `{{media_tmdbid}}` | The media's TMDb ID |
|
||||
| `{{media_tvdbid}}` | The media's TheTVDB ID |
|
||||
| `{{media_status}}` | The media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
|
||||
| `{{media_status4k}}` | The media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
|
||||
|
||||
#### Request
|
||||
|
||||
The `{{request}}` special variables are only included in request-related notifications.
|
||||
The `{{request}}` will be `null` if there is no relevant media object for the notification.
|
||||
|
||||
- `{{request_id}}` Request ID.
|
||||
- `{{requestedBy_username}}` Requesting user's username.
|
||||
- `{{requestedBy_email}}` Requesting user's email address.
|
||||
- `{{requestedBy_avatar}}` Requesting user's avatar URL.
|
||||
- `{{requestedBy_settings_discordId}}` Requesting user's Discord ID (if set).
|
||||
- `{{requestedBy_settings_telegramChatId}}` Requesting user's Telegram Chat ID (if set).
|
||||
The following special variables are only included in request-related notifications.
|
||||
|
||||
| Variable | Value |
|
||||
| ----------------------------------------- | ----------------------------------------------- |
|
||||
| `{{request_id}}` | The request ID |
|
||||
| `{{requestedBy_username}}` | The requesting user's username |
|
||||
| `{{requestedBy_email}}` | The requesting user's email address |
|
||||
| `{{requestedBy_avatar}}` | The requesting user's avatar URL |
|
||||
| `{{requestedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
|
||||
| `{{requestedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
|
||||
|
||||
#### Issue
|
||||
|
||||
The `{{issue}}` will be `null` if there is no relevant media object for the notification.
|
||||
|
||||
The following special variables are only included in issue-related notifications.
|
||||
|
||||
| Variable | Value |
|
||||
| ---------------------------------------- | ----------------------------------------------- |
|
||||
| `{{issue_id}}` | The issue ID |
|
||||
| `{{reportedBy_username}}` | The requesting user's username |
|
||||
| `{{reportedBy_email}}` | The requesting user's email address |
|
||||
| `{{reportedBy_avatar}}` | The requesting user's avatar URL |
|
||||
| `{{reportedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
|
||||
| `{{reportedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
|
||||
|
||||
#### Comment
|
||||
|
||||
The `{{comment}}` will be `null` if there is no relevant media object for the notification.
|
||||
|
||||
The following special variables are only included in issue comment-related notifications.
|
||||
|
||||
| Variable | Value |
|
||||
| ----------------------------------------- | ----------------------------------------------- |
|
||||
| `{{comment_message}}` | The comment message |
|
||||
| `{{commentedBy_username}}` | The commenting user's username |
|
||||
| `{{commentedBy_email}}` | The commenting user's email address |
|
||||
| `{{commentedBy_avatar}}` | The commenting user's avatar URL |
|
||||
| `{{commentedBy_settings_discordId}}` | The commenting user's Discord ID (if set) |
|
||||
| `{{commentedBy_settings_telegramChatId}}` | The commenting user's Telegram Chat ID (if set) |
|
||||
|
||||
3
next-env.d.ts
vendored
3
next-env.d.ts
vendored
@@ -1,3 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/types/global" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
|
||||
@@ -909,6 +909,15 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ProductionCompany'
|
||||
productionCountries:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
iso_3166_1:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
spokenLanguages:
|
||||
type: array
|
||||
items:
|
||||
@@ -1278,6 +1287,27 @@ components:
|
||||
allowSelfSigned:
|
||||
type: boolean
|
||||
example: false
|
||||
Job:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
example: job-name
|
||||
type:
|
||||
type: string
|
||||
enum: [process, command]
|
||||
interval:
|
||||
type: string
|
||||
enum: [short, long, fixed]
|
||||
name:
|
||||
type: string
|
||||
example: A Job Name
|
||||
nextExecutionTime:
|
||||
type: string
|
||||
example: '2020-09-02T05:02:23.000Z'
|
||||
running:
|
||||
type: boolean
|
||||
example: false
|
||||
PersonDetail:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1609,6 +1639,15 @@ components:
|
||||
discordId:
|
||||
type: string
|
||||
nullable: true
|
||||
pushbulletAccessToken:
|
||||
type: string
|
||||
nullable: true
|
||||
pushoverApplicationToken:
|
||||
type: string
|
||||
nullable: true
|
||||
pushoverUserKey:
|
||||
type: string
|
||||
nullable: true
|
||||
telegramEnabled:
|
||||
type: boolean
|
||||
telegramBotUsername:
|
||||
@@ -1666,6 +1705,36 @@ components:
|
||||
type: number
|
||||
name:
|
||||
type: string
|
||||
Issue:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
example: 1
|
||||
issueType:
|
||||
type: number
|
||||
example: 1
|
||||
media:
|
||||
$ref: '#/components/schemas/MediaInfo'
|
||||
createdBy:
|
||||
$ref: '#/components/schemas/User'
|
||||
modifiedBy:
|
||||
$ref: '#/components/schemas/User'
|
||||
comments:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/IssueComment'
|
||||
IssueComment:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
example: 1
|
||||
user:
|
||||
$ref: '#/components/schemas/User'
|
||||
message:
|
||||
type: string
|
||||
example: A comment
|
||||
securitySchemes:
|
||||
cookieAuth:
|
||||
type: apiKey
|
||||
@@ -2214,23 +2283,7 @@ paths:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
example: job-name
|
||||
name:
|
||||
type: string
|
||||
example: A Job Name
|
||||
type:
|
||||
type: string
|
||||
enum: [process, command]
|
||||
nextExecutionTime:
|
||||
type: string
|
||||
example: '2020-09-02T05:02:23.000Z'
|
||||
running:
|
||||
type: boolean
|
||||
example: false
|
||||
$ref: '#/components/schemas/Job'
|
||||
/settings/jobs/{jobId}/run:
|
||||
post:
|
||||
summary: Invoke a specific job
|
||||
@@ -2249,23 +2302,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
example: job-name
|
||||
type:
|
||||
type: string
|
||||
enum: [process, command]
|
||||
name:
|
||||
type: string
|
||||
example: A Job Name
|
||||
nextExecutionTime:
|
||||
type: string
|
||||
example: '2020-09-02T05:02:23.000Z'
|
||||
running:
|
||||
type: boolean
|
||||
example: false
|
||||
$ref: '#/components/schemas/Job'
|
||||
/settings/jobs/{jobId}/cancel:
|
||||
post:
|
||||
summary: Cancel a specific job
|
||||
@@ -2284,23 +2321,36 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
example: job-name
|
||||
type:
|
||||
type: string
|
||||
enum: [process, command]
|
||||
name:
|
||||
type: string
|
||||
example: A Job Name
|
||||
nextExecutionTime:
|
||||
type: string
|
||||
example: '2020-09-02T05:02:23.000Z'
|
||||
running:
|
||||
type: boolean
|
||||
example: false
|
||||
$ref: '#/components/schemas/Job'
|
||||
/settings/jobs/{jobId}/schedule:
|
||||
post:
|
||||
summary: Modify job schedule
|
||||
description: Re-registers the job with the schedule specified. Will return the job in JSON format.
|
||||
tags:
|
||||
- settings
|
||||
parameters:
|
||||
- in: path
|
||||
name: jobId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
schedule:
|
||||
type: string
|
||||
example: '0 */5 * * * *'
|
||||
responses:
|
||||
'200':
|
||||
description: Rescheduled job
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Job'
|
||||
/settings/cache:
|
||||
get:
|
||||
summary: Get a list of active caches
|
||||
@@ -2398,7 +2448,7 @@ paths:
|
||||
example: Server ready on port 5055
|
||||
timestamp:
|
||||
type: string
|
||||
example: 2020-12-15T16:20:00.069Z
|
||||
example: '2020-12-15T16:20:00.069Z'
|
||||
/settings/notifications/email:
|
||||
get:
|
||||
summary: Get email notification settings
|
||||
@@ -5165,7 +5215,267 @@ paths:
|
||||
name:
|
||||
type: string
|
||||
example: Drama
|
||||
/backdrops:
|
||||
get:
|
||||
summary: Get backdrops of trending items
|
||||
description: Returns a list of backdrop image paths in a JSON array.
|
||||
security: []
|
||||
tags:
|
||||
- tmdb
|
||||
responses:
|
||||
'200':
|
||||
description: Results
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
/issue:
|
||||
get:
|
||||
summary: Get all issues
|
||||
description: |
|
||||
Returns a list of issues in JSON format.
|
||||
tags:
|
||||
- issue
|
||||
parameters:
|
||||
- in: query
|
||||
name: take
|
||||
schema:
|
||||
type: number
|
||||
nullable: true
|
||||
example: 20
|
||||
- in: query
|
||||
name: skip
|
||||
schema:
|
||||
type: number
|
||||
nullable: true
|
||||
example: 0
|
||||
- in: query
|
||||
name: sort
|
||||
schema:
|
||||
type: string
|
||||
enum: [added, modified]
|
||||
default: added
|
||||
- in: query
|
||||
name: filter
|
||||
schema:
|
||||
type: string
|
||||
enum: [all, open, resolved]
|
||||
default: open
|
||||
- in: query
|
||||
name: requestedBy
|
||||
schema:
|
||||
type: number
|
||||
nullable: true
|
||||
example: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Issues returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
pageInfo:
|
||||
$ref: '#/components/schemas/PageInfo'
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Issue'
|
||||
post:
|
||||
summary: Create new issue
|
||||
description: |
|
||||
Creates a new issue
|
||||
tags:
|
||||
- issue
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
issueType:
|
||||
type: number
|
||||
message:
|
||||
type: string
|
||||
mediaId:
|
||||
type: number
|
||||
responses:
|
||||
'201':
|
||||
description: Succesfully created the issue
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Issue'
|
||||
/issue/{issueId}:
|
||||
get:
|
||||
summary: Get issue
|
||||
description: |
|
||||
Returns a single issue in JSON format.
|
||||
tags:
|
||||
- issue
|
||||
parameters:
|
||||
- in: path
|
||||
name: issueId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Issues returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Issue'
|
||||
delete:
|
||||
summary: Delete issue
|
||||
description: Removes an issue. If the user has the `MANAGE_ISSUES` permission, any issue can be removed. Otherwise, only a users own issues can be removed.
|
||||
tags:
|
||||
- issue
|
||||
parameters:
|
||||
- in: path
|
||||
name: issueId
|
||||
description: Issue ID
|
||||
required: true
|
||||
example: '1'
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: Succesfully removed issue
|
||||
/issue/{issueId}/comment:
|
||||
post:
|
||||
summary: Create a comment
|
||||
description: |
|
||||
Creates a comment and returns associated issue in JSON format.
|
||||
tags:
|
||||
- issue
|
||||
parameters:
|
||||
- in: path
|
||||
name: issueId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
required:
|
||||
- message
|
||||
responses:
|
||||
'200':
|
||||
description: Issue returned with new comment
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Issue'
|
||||
/issueComment/{commentId}:
|
||||
get:
|
||||
summary: Get issue comment
|
||||
description: |
|
||||
Returns a single issue comment in JSON format.
|
||||
tags:
|
||||
- issue
|
||||
parameters:
|
||||
- in: path
|
||||
name: commentId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Comment returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/IssueComment'
|
||||
put:
|
||||
summary: Update issue comment
|
||||
description: |
|
||||
Updates and returns a single issue comment in JSON format.
|
||||
tags:
|
||||
- issue
|
||||
parameters:
|
||||
- in: path
|
||||
name: commentId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: 1
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Comment updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/IssueComment'
|
||||
delete:
|
||||
summary: Delete issue comment
|
||||
description: |
|
||||
Deletes an issue comment. Only users with `MANAGE_ISSUES` or the user who created the comment can perform this action.
|
||||
tags:
|
||||
- issue
|
||||
parameters:
|
||||
- in: path
|
||||
name: commentId
|
||||
description: Issue Comment ID
|
||||
required: true
|
||||
example: '1'
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: Succesfully removed issue comment
|
||||
/issue/{issueId}/{status}:
|
||||
post:
|
||||
summary: Update an issue's status
|
||||
description: |
|
||||
Updates an issue's status to approved or declined. Also returns the issue in a JSON object.
|
||||
|
||||
Requires the `MANAGE_ISSUES` permission or `ADMIN`.
|
||||
tags:
|
||||
- issue
|
||||
parameters:
|
||||
- in: path
|
||||
name: issueId
|
||||
description: Issue ID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: '1'
|
||||
- in: path
|
||||
name: status
|
||||
description: New status
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [open, resolved]
|
||||
responses:
|
||||
'200':
|
||||
description: Issue status changed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Issue'
|
||||
security:
|
||||
- cookieAuth: []
|
||||
- apiKey: []
|
||||
|
||||
118
package.json
118
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "overseerr",
|
||||
"version": "1.26.0",
|
||||
"version": "1.28.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",
|
||||
@@ -10,40 +10,44 @@
|
||||
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\"",
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault true \"./src/**/!(*.test).{ts,tsx}\"",
|
||||
"migration:generate": "ts-node --project server/tsconfig.json ./node_modules/.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",
|
||||
"migration:generate": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate",
|
||||
"migration:create": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create",
|
||||
"migration:run": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sct/overseerr.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.3.0",
|
||||
"@heroicons/react": "^1.0.1",
|
||||
"@headlessui/react": "^1.4.1",
|
||||
"@heroicons/react": "^1.0.4",
|
||||
"@supercharge/request-ip": "^1.1.2",
|
||||
"@svgr/webpack": "^5.5.0",
|
||||
"@tanem/react-nprogress": "^3.0.70",
|
||||
"@tanem/react-nprogress": "^3.0.79",
|
||||
"ace-builds": "^1.4.12",
|
||||
"axios": "^0.21.1",
|
||||
"axios": "^0.21.4",
|
||||
"bcrypt": "^5.0.1",
|
||||
"bowser": "^2.11.0",
|
||||
"connect-typeorm": "^1.1.4",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
"country-flag-icons": "^1.2.10",
|
||||
"country-flag-icons": "^1.4.10",
|
||||
"csurf": "^1.11.0",
|
||||
"email-templates": "^8.0.7",
|
||||
"email-templates": "^8.0.8",
|
||||
"express": "^4.17.1",
|
||||
"express-openapi-validator": "^4.12.14",
|
||||
"express-rate-limit": "^5.2.6",
|
||||
"express-openapi-validator": "^4.13.1",
|
||||
"express-rate-limit": "^5.3.0",
|
||||
"express-session": "^1.17.2",
|
||||
"formik": "^2.2.9",
|
||||
"gravatar-url": "3.1.0",
|
||||
"intl": "^1.2.5",
|
||||
"lodash": "^4.17.21",
|
||||
"next": "11.0.1",
|
||||
"next": "11.1.2",
|
||||
"node-cache": "^5.1.2",
|
||||
"node-schedule": "^2.0.0",
|
||||
"nodemailer": "^6.6.2",
|
||||
"nodemailer": "^6.6.3",
|
||||
"openpgp": "^5.0.0-3",
|
||||
"plex-api": "^5.3.1",
|
||||
"pug": "^3.0.2",
|
||||
@@ -51,12 +55,12 @@
|
||||
"react-ace": "^9.3.0",
|
||||
"react-animate-height": "^2.0.23",
|
||||
"react-dom": "17.0.2",
|
||||
"react-intersection-observer": "^8.32.0",
|
||||
"react-intl": "5.20.3",
|
||||
"react-intersection-observer": "^8.32.1",
|
||||
"react-intl": "5.20.10",
|
||||
"react-markdown": "^6.0.2",
|
||||
"react-select": "^4.3.1",
|
||||
"react-spring": "^9.2.3",
|
||||
"react-toast-notifications": "^2.4.4",
|
||||
"react-spring": "^9.2.4",
|
||||
"react-toast-notifications": "^2.5.1",
|
||||
"react-transition-group": "^4.4.2",
|
||||
"react-truncate-markup": "^5.1.0",
|
||||
"react-use-clipboard": "1.0.7",
|
||||
@@ -65,8 +69,8 @@
|
||||
"sqlite3": "^5.0.2",
|
||||
"swagger-ui-express": "^4.1.6",
|
||||
"swr": "^0.5.6",
|
||||
"typeorm": "0.2.32",
|
||||
"web-push": "^3.4.4",
|
||||
"typeorm": "0.2.37",
|
||||
"web-push": "^3.4.5",
|
||||
"winston": "^3.3.3",
|
||||
"winston-daily-rotate-file": "^4.5.5",
|
||||
"xml2js": "^0.4.23",
|
||||
@@ -74,66 +78,66 @@
|
||||
"yup": "^0.32.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.14.5",
|
||||
"@commitlint/cli": "^12.1.4",
|
||||
"@commitlint/config-conventional": "^12.1.4",
|
||||
"@babel/cli": "^7.15.7",
|
||||
"@commitlint/cli": "^13.1.0",
|
||||
"@commitlint/config-conventional": "^13.1.0",
|
||||
"@semantic-release/changelog": "^5.0.1",
|
||||
"@semantic-release/commit-analyzer": "^8.0.1",
|
||||
"@semantic-release/commit-analyzer": "^9.0.1",
|
||||
"@semantic-release/exec": "^5.0.0",
|
||||
"@semantic-release/git": "^9.0.0",
|
||||
"@semantic-release/git": "^9.0.1",
|
||||
"@tailwindcss/aspect-ratio": "^0.2.1",
|
||||
"@tailwindcss/forms": "^0.3.3",
|
||||
"@tailwindcss/typography": "^0.4.1",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
"@types/country-flag-icons": "^1.2.0",
|
||||
"@types/csurf": "^1.11.1",
|
||||
"@types/email-templates": "^8.0.3",
|
||||
"@types/express": "^4.17.12",
|
||||
"@types/express-rate-limit": "^5.1.2",
|
||||
"@types/csurf": "^1.11.2",
|
||||
"@types/email-templates": "^8.0.4",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/express-rate-limit": "^5.1.3",
|
||||
"@types/express-session": "^1.17.3",
|
||||
"@types/lodash": "^4.14.170",
|
||||
"@types/lodash": "^4.14.173",
|
||||
"@types/node": "^15.6.1",
|
||||
"@types/node-schedule": "^1.3.1",
|
||||
"@types/nodemailer": "^6.4.2",
|
||||
"@types/react": "^17.0.11",
|
||||
"@types/react-dom": "^17.0.8",
|
||||
"@types/react-select": "^4.0.15",
|
||||
"@types/node-schedule": "^1.3.2",
|
||||
"@types/nodemailer": "^6.4.4",
|
||||
"@types/react": "^17.0.22",
|
||||
"@types/react-dom": "^17.0.9",
|
||||
"@types/react-select": "^4.0.17",
|
||||
"@types/react-toast-notifications": "^2.4.1",
|
||||
"@types/react-transition-group": "^4.4.1",
|
||||
"@types/secure-random-password": "^0.2.0",
|
||||
"@types/swagger-ui-express": "^4.1.2",
|
||||
"@types/web-push": "^3.3.1",
|
||||
"@types/xml2js": "^0.4.8",
|
||||
"@types/react-transition-group": "^4.4.3",
|
||||
"@types/secure-random-password": "^0.2.1",
|
||||
"@types/swagger-ui-express": "^4.1.3",
|
||||
"@types/web-push": "^3.3.2",
|
||||
"@types/xml2js": "^0.4.9",
|
||||
"@types/yamljs": "^0.2.31",
|
||||
"@types/yup": "^0.29.11",
|
||||
"@typescript-eslint/eslint-plugin": "^4.28.0",
|
||||
"@typescript-eslint/parser": "^4.28.0",
|
||||
"autoprefixer": "^10.2.6",
|
||||
"@types/yup": "^0.29.13",
|
||||
"@typescript-eslint/eslint-plugin": "^4.31.1",
|
||||
"@typescript-eslint/parser": "^4.31.1",
|
||||
"autoprefixer": "^10.3.4",
|
||||
"babel-plugin-react-intl": "^8.2.25",
|
||||
"babel-plugin-react-intl-auto": "^3.3.0",
|
||||
"commitizen": "^4.2.4",
|
||||
"copyfiles": "^2.4.1",
|
||||
"cz-conventional-changelog": "^3.3.0",
|
||||
"eslint": "^7.29.0",
|
||||
"eslint-config-next": "^11.0.1",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-next": "^11.1.2",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-formatjs": "^2.16.1",
|
||||
"eslint-plugin-formatjs": "^2.17.6",
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"eslint-plugin-prettier": "^3.4.0",
|
||||
"eslint-plugin-react": "^7.24.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-react": "^7.25.3",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"extract-react-intl-messages": "^4.1.1",
|
||||
"husky": "4.3.8",
|
||||
"lint-staged": "^11.0.0",
|
||||
"nodemon": "^2.0.7",
|
||||
"postcss": "^8.3.5",
|
||||
"prettier": "^2.3.1",
|
||||
"semantic-release": "^17.4.4",
|
||||
"lint-staged": "^11.1.2",
|
||||
"nodemon": "^2.0.12",
|
||||
"postcss": "^8.3.6",
|
||||
"prettier": "^2.4.1",
|
||||
"semantic-release": "^18.0.0",
|
||||
"semantic-release-docker-buildx": "^1.0.1",
|
||||
"tailwindcss": "^2.2.2",
|
||||
"ts-node": "^10.0.0",
|
||||
"typescript": "^4.3.4"
|
||||
"tailwindcss": "^2.2.15",
|
||||
"ts-node": "^10.2.1",
|
||||
"typescript": "^4.4.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"sqlite3/node-gyp": "^5.1.0"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 372 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 407 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 384 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 421 KiB |
16
public/sw.js
16
public/sw.js
@@ -90,8 +90,8 @@ self.addEventListener('push', (event) => {
|
||||
if (payload.actionUrl){
|
||||
options.actions.push(
|
||||
{
|
||||
action: 'viewmedia',
|
||||
title: 'View Media',
|
||||
action: 'view',
|
||||
title: payload.actionUrlTitle ?? 'View',
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -119,21 +119,17 @@ self.addEventListener('notificationclick', (event) => {
|
||||
|
||||
event.notification.close();
|
||||
|
||||
if (event.action === 'viewmedia') {
|
||||
clients.openWindow(notificationData.actionUrl);
|
||||
} else if (event.action === 'approve') {
|
||||
if (event.action === 'approve') {
|
||||
fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
clients.openWindow(notificationData.actionUrl);
|
||||
} else if (event.action === 'decline') {
|
||||
fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
clients.openWindow(notificationData.actionUrl);
|
||||
} else if (notificationData.actionUrl) {
|
||||
}
|
||||
|
||||
if (notificationData.actionUrl) {
|
||||
clients.openWindow(notificationData.actionUrl);
|
||||
}
|
||||
}, false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import NodePlexAPI from 'plex-api';
|
||||
import { getSettings, PlexSettings } from '../lib/settings';
|
||||
import { getSettings, Library, PlexSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
|
||||
export interface PlexLibraryItem {
|
||||
ratingKey: string;
|
||||
@@ -11,11 +12,16 @@ export interface PlexLibraryItem {
|
||||
grandparentGuid?: string;
|
||||
addedAt: number;
|
||||
updatedAt: number;
|
||||
Guid?: {
|
||||
id: string;
|
||||
}[];
|
||||
type: 'movie' | 'show' | 'season' | 'episode';
|
||||
Media: Media[];
|
||||
}
|
||||
|
||||
interface PlexLibraryResponse {
|
||||
MediaContainer: {
|
||||
totalSize: number;
|
||||
Metadata: PlexLibraryItem[];
|
||||
};
|
||||
}
|
||||
@@ -137,12 +143,62 @@ class PlexAPI {
|
||||
return response.MediaContainer.Directory;
|
||||
}
|
||||
|
||||
public async getLibraryContents(id: string): Promise<PlexLibraryItem[]> {
|
||||
const response = await this.plexClient.query<PlexLibraryResponse>(
|
||||
`/library/sections/${id}/all`
|
||||
);
|
||||
public async syncLibraries(): Promise<void> {
|
||||
const settings = getSettings();
|
||||
|
||||
return response.MediaContainer.Metadata ?? [];
|
||||
try {
|
||||
const libraries = await this.getLibraries();
|
||||
|
||||
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,
|
||||
type: library.type,
|
||||
lastScan: existing?.lastScan,
|
||||
};
|
||||
});
|
||||
|
||||
settings.plex.libraries = newLibraries;
|
||||
} catch (e) {
|
||||
logger.error('Failed to fetch Plex libraries', {
|
||||
label: 'Plex API',
|
||||
message: e.message,
|
||||
});
|
||||
|
||||
settings.plex.libraries = [];
|
||||
}
|
||||
|
||||
settings.save();
|
||||
}
|
||||
|
||||
public async getLibraryContents(
|
||||
id: string,
|
||||
{ offset = 0, size = 50 }: { offset?: number; size?: number } = {}
|
||||
): Promise<{ totalSize: number; items: PlexLibraryItem[] }> {
|
||||
const response = await this.plexClient.query<PlexLibraryResponse>({
|
||||
uri: `/library/sections/${id}/all?includeGuids=1`,
|
||||
extraHeaders: {
|
||||
'X-Plex-Container-Start': `${offset}`,
|
||||
'X-Plex-Container-Size': `${size}`,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
totalSize: response.MediaContainer.totalSize,
|
||||
items: response.MediaContainer.Metadata ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
public async getMetadata(
|
||||
@@ -166,10 +222,17 @@ class PlexAPI {
|
||||
return response.MediaContainer.Metadata;
|
||||
}
|
||||
|
||||
public async getRecentlyAdded(id: string): Promise<PlexLibraryItem[]> {
|
||||
const response = await this.plexClient.query<PlexLibraryResponse>(
|
||||
`/library/sections/${id}/recentlyAdded`
|
||||
);
|
||||
public async getRecentlyAdded(
|
||||
id: string,
|
||||
options: { addedAt: number } = {
|
||||
addedAt: Date.now() - 1000 * 60 * 60,
|
||||
}
|
||||
): Promise<PlexLibraryItem[]> {
|
||||
const response = await this.plexClient.query<PlexLibraryResponse>({
|
||||
uri: `/library/sections/${id}/all?sort=addedAt%3Adesc&addedAt>>=${Math.floor(
|
||||
options.addedAt / 1000
|
||||
)}`,
|
||||
});
|
||||
|
||||
return response.MediaContainer.Metadata;
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ class RottenTomatoes extends ExternalAPI {
|
||||
|
||||
return {
|
||||
title: movie.name,
|
||||
url: movie.url,
|
||||
url: `https://www.rottentomatoes.com${movie.url}`,
|
||||
criticsRating:
|
||||
movie.meterClass === 'certified_fresh'
|
||||
? 'Certified Fresh'
|
||||
|
||||
@@ -2,6 +2,35 @@ import cacheManager, { AvailableCacheIds } from '../../lib/cache';
|
||||
import { DVRSettings } from '../../lib/settings';
|
||||
import ExternalAPI from '../externalapi';
|
||||
|
||||
export interface SystemStatus {
|
||||
version: string;
|
||||
buildTime: Date;
|
||||
isDebug: boolean;
|
||||
isProduction: boolean;
|
||||
isAdmin: boolean;
|
||||
isUserInteractive: boolean;
|
||||
startupPath: string;
|
||||
appData: string;
|
||||
osName: string;
|
||||
osVersion: string;
|
||||
isNetCore: boolean;
|
||||
isMono: boolean;
|
||||
isLinux: boolean;
|
||||
isOsx: boolean;
|
||||
isWindows: boolean;
|
||||
isDocker: boolean;
|
||||
mode: string;
|
||||
branch: string;
|
||||
authentication: string;
|
||||
sqliteVersion: string;
|
||||
migrationVersion: number;
|
||||
urlBase: string;
|
||||
runtimeVersion: string;
|
||||
runtimeName: string;
|
||||
startTime: Date;
|
||||
packageUpdateMechanism: string;
|
||||
}
|
||||
|
||||
export interface RootFolder {
|
||||
id: number;
|
||||
path: string;
|
||||
@@ -81,6 +110,18 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
||||
this.apiName = apiName;
|
||||
}
|
||||
|
||||
public getSystemStatus = async (): Promise<SystemStatus> => {
|
||||
try {
|
||||
const response = await this.axios.get<SystemStatus>('/system/status');
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[${this.apiName}] Failed to retrieve system status: ${e.message}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
public getProfiles = async (): Promise<QualityProfile[]> => {
|
||||
try {
|
||||
const data = await this.getRolling<QualityProfile[]>(
|
||||
|
||||
@@ -251,6 +251,10 @@ export interface TmdbTvDetails {
|
||||
name: string;
|
||||
origin_country: string;
|
||||
}[];
|
||||
production_countries: {
|
||||
iso_3166_1: string;
|
||||
name: string;
|
||||
}[];
|
||||
spoken_languages: {
|
||||
english_name: string;
|
||||
iso_639_1: string;
|
||||
|
||||
18
server/constants/issue.ts
Normal file
18
server/constants/issue.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export enum IssueType {
|
||||
VIDEO = 1,
|
||||
AUDIO = 2,
|
||||
SUBTITLES = 3,
|
||||
OTHER = 4,
|
||||
}
|
||||
|
||||
export enum IssueStatus {
|
||||
OPEN = 1,
|
||||
RESOLVED = 2,
|
||||
}
|
||||
|
||||
export const IssueTypeName = {
|
||||
[IssueType.AUDIO]: 'Audio',
|
||||
[IssueType.VIDEO]: 'Video',
|
||||
[IssueType.SUBTITLES]: 'Subtitle',
|
||||
[IssueType.OTHER]: 'Other',
|
||||
};
|
||||
68
server/entity/Issue.ts
Normal file
68
server/entity/Issue.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { IssueStatus, IssueType } from '../constants/issue';
|
||||
import IssueComment from './IssueComment';
|
||||
import Media from './Media';
|
||||
import { User } from './User';
|
||||
|
||||
@Entity()
|
||||
class Issue {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
public issueType: IssueType;
|
||||
|
||||
@Column({ type: 'int', default: IssueStatus.OPEN })
|
||||
public status: IssueStatus;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
public problemSeason: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
public problemEpisode: number;
|
||||
|
||||
@ManyToOne(() => Media, (media) => media.issues, {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
public media: Media;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.createdIssues, {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
public createdBy: User;
|
||||
|
||||
@ManyToOne(() => User, {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
nullable: true,
|
||||
})
|
||||
public modifiedBy?: User;
|
||||
|
||||
@OneToMany(() => IssueComment, (comment) => comment.issue, {
|
||||
cascade: true,
|
||||
eager: true,
|
||||
})
|
||||
public comments: IssueComment[];
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
public updatedAt: Date;
|
||||
|
||||
constructor(init?: Partial<Issue>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
}
|
||||
|
||||
export default Issue;
|
||||
42
server/entity/IssueComment.ts
Normal file
42
server/entity/IssueComment.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import Issue from './Issue';
|
||||
import { User } from './User';
|
||||
|
||||
@Entity()
|
||||
class IssueComment {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@ManyToOne(() => User, {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
public user: User;
|
||||
|
||||
@ManyToOne(() => Issue, (issue) => issue.comments, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
public issue: Issue;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
public message: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
public updatedAt: Date;
|
||||
|
||||
constructor(init?: Partial<IssueComment>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
}
|
||||
|
||||
export default IssueComment;
|
||||
@@ -16,6 +16,7 @@ import { MediaStatus, MediaType } from '../constants/media';
|
||||
import downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import Issue from './Issue';
|
||||
import { MediaRequest } from './MediaRequest';
|
||||
import Season from './Season';
|
||||
|
||||
@@ -54,7 +55,7 @@ class Media {
|
||||
try {
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { tmdbId: id, mediaType },
|
||||
relations: ['requests'],
|
||||
relations: ['requests', 'issues'],
|
||||
});
|
||||
|
||||
return media;
|
||||
@@ -97,6 +98,9 @@ class Media {
|
||||
})
|
||||
public seasons: Season[];
|
||||
|
||||
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
|
||||
public issues: Issue[];
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
|
||||
|
||||
@@ -142,6 +142,7 @@ export class MediaRequest {
|
||||
if (this.type === MediaType.MOVIE) {
|
||||
const movie = await tmdb.getMovie({ movieId: media.tmdbId });
|
||||
notificationManager.sendNotification(Notification.MEDIA_PENDING, {
|
||||
event: `New ${this.is4k ? '4K ' : ''}Movie Request`,
|
||||
subject: `${movie.title}${
|
||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
@@ -153,12 +154,14 @@ export class MediaRequest {
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
media,
|
||||
request: this,
|
||||
notifyAdmin: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.type === MediaType.TV) {
|
||||
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||
notificationManager.sendNotification(Notification.MEDIA_PENDING, {
|
||||
event: `New ${this.is4k ? '4K ' : ''}Series Request`,
|
||||
subject: `${tv.name}${
|
||||
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
@@ -171,13 +174,14 @@ export class MediaRequest {
|
||||
media,
|
||||
extra: [
|
||||
{
|
||||
name: 'Seasons',
|
||||
name: 'Requested Seasons',
|
||||
value: this.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
request: this,
|
||||
notifyAdmin: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -222,6 +226,13 @@ export class MediaRequest {
|
||||
: Notification.MEDIA_APPROVED
|
||||
: Notification.MEDIA_DECLINED,
|
||||
{
|
||||
event: `${this.is4k ? '4K ' : ''}Movie Request ${
|
||||
this.status === MediaRequestStatus.APPROVED
|
||||
? autoApproved
|
||||
? 'Automatically Approved'
|
||||
: 'Approved'
|
||||
: 'Declined'
|
||||
}`,
|
||||
subject: `${movie.title}${
|
||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
@@ -231,6 +242,7 @@ export class MediaRequest {
|
||||
omission: '…',
|
||||
}),
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
notifyAdmin: autoApproved,
|
||||
notifyUser: autoApproved ? undefined : this.requestedBy,
|
||||
media,
|
||||
request: this,
|
||||
@@ -245,6 +257,13 @@ export class MediaRequest {
|
||||
: Notification.MEDIA_APPROVED
|
||||
: Notification.MEDIA_DECLINED,
|
||||
{
|
||||
event: `${this.is4k ? '4K ' : ''}Series Request ${
|
||||
this.status === MediaRequestStatus.APPROVED
|
||||
? autoApproved
|
||||
? 'Automatically Approved'
|
||||
: 'Approved'
|
||||
: 'Declined'
|
||||
}`,
|
||||
subject: `${tv.name}${
|
||||
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
@@ -254,11 +273,12 @@ export class MediaRequest {
|
||||
omission: '…',
|
||||
}),
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||
notifyAdmin: autoApproved,
|
||||
notifyUser: autoApproved ? undefined : this.requestedBy,
|
||||
media,
|
||||
extra: [
|
||||
{
|
||||
name: 'Seasons',
|
||||
name: 'Requested Seasons',
|
||||
value: this.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
@@ -508,6 +528,7 @@ export class MediaRequest {
|
||||
);
|
||||
|
||||
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
|
||||
event: `${this.is4k ? '4K ' : ''}Movie Request Failed`,
|
||||
subject: `${movie.title}${
|
||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
@@ -519,6 +540,7 @@ export class MediaRequest {
|
||||
media,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
request: this,
|
||||
notifyAdmin: true,
|
||||
});
|
||||
});
|
||||
logger.info('Sent request to Radarr', { label: 'Media Request' });
|
||||
@@ -722,6 +744,7 @@ export class MediaRequest {
|
||||
);
|
||||
|
||||
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
|
||||
event: `${this.is4k ? '4K ' : ''}Series Request Failed`,
|
||||
subject: `${series.name}${
|
||||
series.first_air_date
|
||||
? ` (${series.first_air_date.slice(0, 4)})`
|
||||
@@ -736,13 +759,14 @@ export class MediaRequest {
|
||||
media,
|
||||
extra: [
|
||||
{
|
||||
name: 'Seasons',
|
||||
name: 'Requested Seasons',
|
||||
value: this.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
request: this,
|
||||
notifyAdmin: true,
|
||||
});
|
||||
});
|
||||
logger.info('Sent request to Sonarr', { label: 'Media Request' });
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from '../lib/permissions';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import Issue from './Issue';
|
||||
import { MediaRequest } from './MediaRequest';
|
||||
import SeasonRequest from './SeasonRequest';
|
||||
import { UserPushSubscription } from './UserPushSubscription';
|
||||
@@ -115,6 +116,9 @@ export class User {
|
||||
@OneToMany(() => UserPushSubscription, (pushSub) => pushSub.user)
|
||||
public pushSubscriptions: UserPushSubscription[];
|
||||
|
||||
@OneToMany(() => Issue, (issue) => issue.createdBy, { cascade: true })
|
||||
public createdIssues: Issue[];
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
|
||||
@@ -178,6 +182,7 @@ export class User {
|
||||
password: password,
|
||||
applicationUrl,
|
||||
applicationTitle,
|
||||
recipientName: this.username,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -214,6 +219,8 @@ export class User {
|
||||
resetPasswordLink,
|
||||
applicationUrl,
|
||||
applicationTitle,
|
||||
recipientName: this.displayName,
|
||||
recipientEmail: this.email,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
@@ -42,6 +42,15 @@ export class UserSettings {
|
||||
@Column({ nullable: true })
|
||||
public discordId?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public pushbulletAccessToken?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public pushoverApplicationToken?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public pushoverUserKey?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public telegramChatId?: string;
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ import path from 'path';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import { createConnection, getRepository } from 'typeorm';
|
||||
import YAML from 'yamljs';
|
||||
import PlexAPI from './api/plexapi';
|
||||
import { Session } from './entity/Session';
|
||||
import { User } from './entity/User';
|
||||
import { startJobs } from './job/schedule';
|
||||
import notificationManager from './lib/notifications';
|
||||
import DiscordAgent from './lib/notifications/agents/discord';
|
||||
@@ -49,6 +51,27 @@ app
|
||||
// Load Settings
|
||||
const settings = getSettings().load();
|
||||
|
||||
// Migrate library types
|
||||
if (
|
||||
settings.plex.libraries.length > 1 &&
|
||||
!settings.plex.libraries[0].type
|
||||
) {
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOne({
|
||||
select: ['id', 'plexToken'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
|
||||
if (admin) {
|
||||
logger.info('Migrating Plex libraries to include media type', {
|
||||
label: 'Settings',
|
||||
});
|
||||
|
||||
const plexapi = new PlexAPI({ plexToken: admin.plexToken });
|
||||
await plexapi.syncLibraries();
|
||||
}
|
||||
}
|
||||
|
||||
// Register Notification Agents
|
||||
notificationManager.registerAgents([
|
||||
new DiscordAgent(),
|
||||
@@ -116,6 +139,9 @@ app
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
maxAge: 1000 * 60 * 60 * 24 * 30,
|
||||
httpOnly: true,
|
||||
sameSite: true,
|
||||
secure: 'auto',
|
||||
},
|
||||
store: new TypeormStore({
|
||||
cleanupLimit: 2,
|
||||
|
||||
6
server/interfaces/api/issueInterfaces.ts
Normal file
6
server/interfaces/api/issueInterfaces.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import Issue from '../../entity/Issue';
|
||||
import { PaginatedResponse } from './common';
|
||||
|
||||
export interface IssueResultsResponse extends PaginatedResponse {
|
||||
results: Issue[];
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { PaginatedResponse } from './common';
|
||||
export type LogMessage = {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
label: string;
|
||||
label?: string;
|
||||
message: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -22,6 +22,9 @@ export interface UserSettingsNotificationsResponse {
|
||||
discordEnabled?: boolean;
|
||||
discordEnabledTypes?: number;
|
||||
discordId?: string;
|
||||
pushbulletAccessToken?: string;
|
||||
pushoverApplicationToken?: string;
|
||||
pushoverUserKey?: string;
|
||||
telegramEnabled?: boolean;
|
||||
telegramBotUsername?: string;
|
||||
telegramChatId?: string;
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import schedule from 'node-schedule';
|
||||
import logger from '../logger';
|
||||
import downloadTracker from '../lib/downloadtracker';
|
||||
import { plexFullScanner, plexRecentScanner } from '../lib/scanners/plex';
|
||||
import { radarrScanner } from '../lib/scanners/radarr';
|
||||
import { sonarrScanner } from '../lib/scanners/sonarr';
|
||||
import { getSettings, JobId } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
|
||||
interface ScheduledJob {
|
||||
id: string;
|
||||
id: JobId;
|
||||
job: schedule.Job;
|
||||
name: string;
|
||||
type: 'process' | 'command';
|
||||
interval: 'short' | 'long' | 'fixed';
|
||||
running?: () => boolean;
|
||||
cancelFn?: () => void;
|
||||
}
|
||||
@@ -17,12 +19,15 @@ interface ScheduledJob {
|
||||
export const scheduledJobs: ScheduledJob[] = [];
|
||||
|
||||
export const startJobs = (): void => {
|
||||
const jobs = getSettings().jobs;
|
||||
|
||||
// Run recently added plex scan every 5 minutes
|
||||
scheduledJobs.push({
|
||||
id: 'plex-recently-added-scan',
|
||||
name: 'Plex Recently Added Scan',
|
||||
type: 'process',
|
||||
job: schedule.scheduleJob('0 */5 * * * *', () => {
|
||||
interval: 'short',
|
||||
job: schedule.scheduleJob(jobs['plex-recently-added-scan'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Plex Recently Added Scan', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
@@ -37,7 +42,8 @@ export const startJobs = (): void => {
|
||||
id: 'plex-full-scan',
|
||||
name: 'Plex Full Library Scan',
|
||||
type: 'process',
|
||||
job: schedule.scheduleJob('0 0 3 * * *', () => {
|
||||
interval: 'long',
|
||||
job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Plex Full Library Scan', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
@@ -52,7 +58,8 @@ export const startJobs = (): void => {
|
||||
id: 'radarr-scan',
|
||||
name: 'Radarr Scan',
|
||||
type: 'process',
|
||||
job: schedule.scheduleJob('0 0 4 * * *', () => {
|
||||
interval: 'long',
|
||||
job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
|
||||
radarrScanner.run();
|
||||
}),
|
||||
@@ -65,7 +72,8 @@ export const startJobs = (): void => {
|
||||
id: 'sonarr-scan',
|
||||
name: 'Sonarr Scan',
|
||||
type: 'process',
|
||||
job: schedule.scheduleJob('0 30 4 * * *', () => {
|
||||
interval: 'long',
|
||||
job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
|
||||
sonarrScanner.run();
|
||||
}),
|
||||
@@ -73,23 +81,27 @@ export const startJobs = (): void => {
|
||||
cancelFn: () => sonarrScanner.cancel(),
|
||||
});
|
||||
|
||||
// Run download sync
|
||||
// Run download sync every minute
|
||||
scheduledJobs.push({
|
||||
id: 'download-sync',
|
||||
name: 'Download Sync',
|
||||
type: 'command',
|
||||
job: schedule.scheduleJob('0 * * * * *', () => {
|
||||
logger.debug('Starting scheduled job: Download Sync', { label: 'Jobs' });
|
||||
interval: 'fixed',
|
||||
job: schedule.scheduleJob(jobs['download-sync'].schedule, () => {
|
||||
logger.debug('Starting scheduled job: Download Sync', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
downloadTracker.updateDownloads();
|
||||
}),
|
||||
});
|
||||
|
||||
// Reset download sync
|
||||
// Reset download sync everyday at 01:00 am
|
||||
scheduledJobs.push({
|
||||
id: 'download-sync-reset',
|
||||
name: 'Download Sync Reset',
|
||||
type: 'command',
|
||||
job: schedule.scheduleJob('0 0 1 * * *', () => {
|
||||
interval: 'long',
|
||||
job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Download Sync Reset', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import NodeCache from 'node-cache';
|
||||
|
||||
export type AvailableCacheIds = 'tmdb' | 'radarr' | 'sonarr' | 'rt' | 'github';
|
||||
export type AvailableCacheIds =
|
||||
| 'tmdb'
|
||||
| 'radarr'
|
||||
| 'sonarr'
|
||||
| 'rt'
|
||||
| 'github'
|
||||
| 'plexguid';
|
||||
|
||||
const DEFAULT_TTL = 300;
|
||||
const DEFAULT_CHECK_PERIOD = 120;
|
||||
@@ -34,7 +40,7 @@ class Cache {
|
||||
|
||||
class CacheManager {
|
||||
private availableCaches: Record<AvailableCacheIds, Cache> = {
|
||||
tmdb: new Cache('tmdb', 'TMDb API', {
|
||||
tmdb: new Cache('tmdb', 'The Movie Database API', {
|
||||
stdTtl: 21600,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
@@ -48,6 +54,10 @@ class CacheManager {
|
||||
stdTtl: 21600,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
plexguid: new Cache('plexguid', 'Plex GUID', {
|
||||
stdTtl: 86400 * 7, // 1 week cache
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
};
|
||||
|
||||
public getCache(id: AvailableCacheIds): Cache {
|
||||
|
||||
@@ -76,23 +76,32 @@ class DownloadTracker {
|
||||
url: RadarrAPI.buildUrl(server, '/api/v3'),
|
||||
});
|
||||
|
||||
const queueItems = await radarr.getQueue();
|
||||
try {
|
||||
const queueItems = await radarr.getQueue();
|
||||
|
||||
this.radarrServers[server.id] = queueItems.map((item) => ({
|
||||
externalId: item.movieId,
|
||||
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
|
||||
mediaType: MediaType.MOVIE,
|
||||
size: item.size,
|
||||
sizeLeft: item.sizeleft,
|
||||
status: item.status,
|
||||
timeLeft: item.timeleft,
|
||||
title: item.title,
|
||||
}));
|
||||
this.radarrServers[server.id] = queueItems.map((item) => ({
|
||||
externalId: item.movieId,
|
||||
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
|
||||
mediaType: MediaType.MOVIE,
|
||||
size: item.size,
|
||||
sizeLeft: item.sizeleft,
|
||||
status: item.status,
|
||||
timeLeft: item.timeleft,
|
||||
title: item.title,
|
||||
}));
|
||||
|
||||
if (queueItems.length > 0) {
|
||||
logger.debug(
|
||||
`Found ${queueItems.length} item(s) in progress on Radarr server: ${server.name}`,
|
||||
{ label: 'Download Tracker' }
|
||||
if (queueItems.length > 0) {
|
||||
logger.debug(
|
||||
`Found ${queueItems.length} item(s) in progress on Radarr server: ${server.name}`,
|
||||
{ label: 'Download Tracker' }
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
logger.error(
|
||||
`Unable to get queue from Radarr server: ${server.name}`,
|
||||
{
|
||||
label: 'Download Tracker',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,42 +143,51 @@ class DownloadTracker {
|
||||
);
|
||||
});
|
||||
|
||||
// Load downloads from Radarr servers
|
||||
// Load downloads from Sonarr servers
|
||||
Promise.all(
|
||||
filteredServers.map(async (server) => {
|
||||
if (server.syncEnabled) {
|
||||
const radarr = new SonarrAPI({
|
||||
const sonarr = new SonarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: SonarrAPI.buildUrl(server, '/api/v3'),
|
||||
});
|
||||
|
||||
const queueItems = await radarr.getQueue();
|
||||
try {
|
||||
const queueItems = await sonarr.getQueue();
|
||||
|
||||
this.sonarrServers[server.id] = queueItems.map((item) => ({
|
||||
externalId: item.seriesId,
|
||||
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
|
||||
mediaType: MediaType.TV,
|
||||
size: item.size,
|
||||
sizeLeft: item.sizeleft,
|
||||
status: item.status,
|
||||
timeLeft: item.timeleft,
|
||||
title: item.title,
|
||||
}));
|
||||
this.sonarrServers[server.id] = queueItems.map((item) => ({
|
||||
externalId: item.seriesId,
|
||||
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
|
||||
mediaType: MediaType.TV,
|
||||
size: item.size,
|
||||
sizeLeft: item.sizeleft,
|
||||
status: item.status,
|
||||
timeLeft: item.timeleft,
|
||||
title: item.title,
|
||||
}));
|
||||
|
||||
if (queueItems.length > 0) {
|
||||
logger.debug(
|
||||
`Found ${queueItems.length} item(s) in progress on Sonarr server: ${server.name}`,
|
||||
{ label: 'Download Tracker' }
|
||||
if (queueItems.length > 0) {
|
||||
logger.debug(
|
||||
`Found ${queueItems.length} item(s) in progress on Sonarr server: ${server.name}`,
|
||||
{ label: 'Download Tracker' }
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
logger.error(
|
||||
`Unable to get queue from Sonarr server: ${server.name}`,
|
||||
{
|
||||
label: 'Download Tracker',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Duplicate this data to matching servers
|
||||
const matchingServers = settings.sonarr.filter(
|
||||
(rs) =>
|
||||
rs.hostname === server.hostname &&
|
||||
rs.port === server.port &&
|
||||
rs.baseUrl === server.baseUrl &&
|
||||
rs.id !== server.id
|
||||
(ss) =>
|
||||
ss.hostname === server.hostname &&
|
||||
ss.port === server.port &&
|
||||
ss.baseUrl === server.baseUrl &&
|
||||
ss.id !== server.id
|
||||
);
|
||||
|
||||
if (matchingServers.length > 0) {
|
||||
|
||||
@@ -51,11 +51,12 @@ class PGPEncryptor extends Transform {
|
||||
|
||||
// Only sign the message if private key and password exist
|
||||
if (this._signingKey && this._password) {
|
||||
privateKey = await openpgp.readPrivateKey({
|
||||
armoredKey: this._signingKey,
|
||||
privateKey = await openpgp.decryptKey({
|
||||
privateKey: await openpgp.readPrivateKey({
|
||||
armoredKey: this._signingKey,
|
||||
}),
|
||||
passphrase: this._password,
|
||||
});
|
||||
|
||||
await openpgp.decryptKey({ privateKey, passphrase: this._password });
|
||||
}
|
||||
|
||||
const emailPartDelimiter = '\r\n\r\n';
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { Notification } from '..';
|
||||
import type Issue from '../../../entity/Issue';
|
||||
import IssueComment from '../../../entity/IssueComment';
|
||||
import Media from '../../../entity/Media';
|
||||
import { MediaRequest } from '../../../entity/MediaRequest';
|
||||
import { User } from '../../../entity/User';
|
||||
import { NotificationAgentConfig } from '../../settings';
|
||||
|
||||
export interface NotificationPayload {
|
||||
event?: string;
|
||||
subject: string;
|
||||
notifyAdmin: boolean;
|
||||
notifyUser?: User;
|
||||
media?: Media;
|
||||
image?: string;
|
||||
message?: string;
|
||||
extra?: { name: string; value: string }[];
|
||||
request?: MediaRequest;
|
||||
issue?: Issue;
|
||||
comment?: IssueComment;
|
||||
}
|
||||
|
||||
export abstract class BaseAgent<T extends NotificationAgentConfig> {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import axios from 'axios';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||
import { User } from '../../../entity/User';
|
||||
import logger from '../../../logger';
|
||||
import { Permission } from '../../permissions';
|
||||
import {
|
||||
getSettings,
|
||||
NotificationAgentDiscord,
|
||||
@@ -107,9 +111,9 @@ class DiscordAgent
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): DiscordRichEmbed {
|
||||
const settings = getSettings();
|
||||
let color = EmbedColors.DARK_PURPLE;
|
||||
const { applicationUrl } = getSettings().main;
|
||||
|
||||
let color = EmbedColors.DARK_PURPLE;
|
||||
const fields: Field[] = [];
|
||||
|
||||
if (payload.request) {
|
||||
@@ -118,56 +122,94 @@ class DiscordAgent
|
||||
value: payload.request.requestedBy.displayName,
|
||||
inline: true,
|
||||
});
|
||||
|
||||
let status = '';
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
color = EmbedColors.ORANGE;
|
||||
status = 'Pending Approval';
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
color = EmbedColors.PURPLE;
|
||||
status = 'Processing';
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
color = EmbedColors.GREEN;
|
||||
status = 'Available';
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
color = EmbedColors.RED;
|
||||
status = 'Declined';
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
color = EmbedColors.RED;
|
||||
status = 'Failed';
|
||||
break;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
fields.push({
|
||||
name: 'Request Status',
|
||||
value: status,
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
} else if (payload.comment) {
|
||||
fields.push({
|
||||
name: `Comment from ${payload.comment.user.displayName}`,
|
||||
value: payload.comment.message,
|
||||
inline: false,
|
||||
});
|
||||
} else if (payload.issue) {
|
||||
fields.push(
|
||||
{
|
||||
name: 'Reported By',
|
||||
value: payload.issue.createdBy.displayName,
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: 'Issue Type',
|
||||
value: IssueTypeName[payload.issue.issueType],
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: 'Issue Status',
|
||||
value:
|
||||
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved',
|
||||
inline: true,
|
||||
}
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case Notification.ISSUE_CREATED:
|
||||
case Notification.ISSUE_REOPENED:
|
||||
color = EmbedColors.RED;
|
||||
break;
|
||||
case Notification.ISSUE_COMMENT:
|
||||
color = EmbedColors.ORANGE;
|
||||
break;
|
||||
case Notification.ISSUE_RESOLVED:
|
||||
color = EmbedColors.GREEN;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
color = EmbedColors.ORANGE;
|
||||
fields.push({
|
||||
name: 'Status',
|
||||
value: 'Pending Approval',
|
||||
inline: true,
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
color = EmbedColors.PURPLE;
|
||||
fields.push({
|
||||
name: 'Status',
|
||||
value: 'Processing',
|
||||
inline: true,
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
color = EmbedColors.GREEN;
|
||||
fields.push({
|
||||
name: 'Status',
|
||||
value: 'Available',
|
||||
inline: true,
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
color = EmbedColors.RED;
|
||||
fields.push({
|
||||
name: 'Status',
|
||||
value: 'Declined',
|
||||
inline: true,
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
color = EmbedColors.RED;
|
||||
fields.push({
|
||||
name: 'Status',
|
||||
value: 'Failed',
|
||||
inline: true,
|
||||
});
|
||||
break;
|
||||
for (const extra of payload.extra ?? []) {
|
||||
fields.push({
|
||||
name: extra.name,
|
||||
value: extra.value,
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
|
||||
const url =
|
||||
settings.main.applicationUrl && payload.media
|
||||
? `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined;
|
||||
const url = applicationUrl
|
||||
? payload.issue
|
||||
? `${applicationUrl}/issues/${payload.issue.id}`
|
||||
: payload.media
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
title: payload.subject,
|
||||
@@ -175,18 +217,12 @@ class DiscordAgent
|
||||
description: payload.message,
|
||||
color,
|
||||
timestamp: new Date().toISOString(),
|
||||
author: {
|
||||
name: settings.main.applicationTitle,
|
||||
url: settings.main.applicationUrl,
|
||||
},
|
||||
fields: [
|
||||
...fields,
|
||||
// If we have extra data, map it to fields for discord notifications
|
||||
...(payload.extra ?? []).map((extra) => ({
|
||||
name: extra.name,
|
||||
value: extra.value,
|
||||
})),
|
||||
],
|
||||
author: payload.event
|
||||
? {
|
||||
name: payload.event,
|
||||
}
|
||||
: undefined,
|
||||
fields,
|
||||
thumbnail: {
|
||||
url: payload.image,
|
||||
},
|
||||
@@ -219,54 +255,53 @@ class DiscordAgent
|
||||
subject: payload.subject,
|
||||
});
|
||||
|
||||
let content = undefined;
|
||||
const userMentions: string[] = [];
|
||||
|
||||
try {
|
||||
if (payload.notifyUser) {
|
||||
// Mention user who submitted the request
|
||||
if (
|
||||
payload.notifyUser.settings?.hasNotificationType(
|
||||
NotificationAgentKey.DISCORD,
|
||||
type
|
||||
) &&
|
||||
payload.notifyUser.settings?.discordId
|
||||
payload.notifyUser.settings.discordId
|
||||
) {
|
||||
content = `<@${payload.notifyUser.settings.discordId}>`;
|
||||
userMentions.push(`<@${payload.notifyUser.settings.discordId}>`);
|
||||
}
|
||||
} else {
|
||||
// Mention all users with the Manage Requests permission
|
||||
}
|
||||
|
||||
if (payload.notifyAdmin) {
|
||||
const userRepository = getRepository(User);
|
||||
const users = await userRepository.find();
|
||||
|
||||
content = users
|
||||
.filter(
|
||||
(user) =>
|
||||
user.hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
user.settings?.hasNotificationType(
|
||||
NotificationAgentKey.DISCORD,
|
||||
type
|
||||
) &&
|
||||
user.settings?.discordId &&
|
||||
// Check if it's the user's own auto-approved request
|
||||
(type !== Notification.MEDIA_AUTO_APPROVED ||
|
||||
user.id !== payload.request?.requestedBy.id)
|
||||
)
|
||||
.map((user) => `<@${user.settings?.discordId}>`)
|
||||
.join(' ');
|
||||
userMentions.push(
|
||||
...users
|
||||
.filter(
|
||||
(user) =>
|
||||
user.settings?.hasNotificationType(
|
||||
NotificationAgentKey.DISCORD,
|
||||
type
|
||||
) &&
|
||||
user.settings.discordId &&
|
||||
shouldSendAdminNotification(type, user, payload)
|
||||
)
|
||||
.map((user) => `<@${user.settings?.discordId}>`)
|
||||
);
|
||||
}
|
||||
|
||||
await axios.post(settings.options.webhookUrl, {
|
||||
username: settings.options.botUsername,
|
||||
username: settings.options.botUsername
|
||||
? settings.options.botUsername
|
||||
: getSettings().main.applicationTitle,
|
||||
avatar_url: settings.options.botAvatarUrl,
|
||||
embeds: [this.buildEmbed(type, payload)],
|
||||
content,
|
||||
content: userMentions.join(' '),
|
||||
} as DiscordWebhookPayload);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Error sending Discord notification', {
|
||||
label: 'Notifications',
|
||||
mentions: content,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { EmailOptions } from 'email-templates';
|
||||
import path from 'path';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { Notification } from '..';
|
||||
import { Notification, shouldSendAdminNotification } from '..';
|
||||
import { IssueType, IssueTypeName } from '../../../constants/issue';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
import { User } from '../../../entity/User';
|
||||
import logger from '../../../logger';
|
||||
import PreparedEmail from '../../email';
|
||||
import { Permission } from '../../permissions';
|
||||
import {
|
||||
getSettings,
|
||||
NotificationAgentEmail,
|
||||
@@ -46,7 +46,8 @@ class EmailAgent
|
||||
private buildMessage(
|
||||
type: Notification,
|
||||
payload: NotificationPayload,
|
||||
toEmail: string
|
||||
recipientEmail: string,
|
||||
recipientName?: string
|
||||
): EmailOptions | undefined {
|
||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||
|
||||
@@ -54,69 +55,59 @@ class EmailAgent
|
||||
return {
|
||||
template: path.join(__dirname, '../../../templates/email/test-email'),
|
||||
message: {
|
||||
to: toEmail,
|
||||
to: recipientEmail,
|
||||
},
|
||||
locals: {
|
||||
body: payload.message,
|
||||
applicationUrl,
|
||||
applicationTitle,
|
||||
recipientName,
|
||||
recipientEmail,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.media) {
|
||||
let requestType = '';
|
||||
const mediaType = payload.media
|
||||
? payload.media.mediaType === MediaType.MOVIE
|
||||
? 'movie'
|
||||
: 'series'
|
||||
: undefined;
|
||||
const is4k = payload.request?.is4k;
|
||||
|
||||
if (payload.request) {
|
||||
let body = '';
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
requestType = `New ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
body = `A user has requested a new ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
}!`;
|
||||
body = `A new request for the following ${mediaType} ${
|
||||
is4k ? 'in 4K ' : ''
|
||||
}is pending approval:`;
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
requestType = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Approved`;
|
||||
body = `Your request for the following ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} has been approved:`;
|
||||
body = `Your request for the following ${mediaType} ${
|
||||
is4k ? 'in 4K ' : ''
|
||||
}has been approved:`;
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
requestType = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Automatically Approved`;
|
||||
body = `A new request for the following ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} has been automatically approved:`;
|
||||
body = `A new request for the following ${mediaType} ${
|
||||
is4k ? 'in 4K ' : ''
|
||||
}has been automatically approved:`;
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
requestType = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Now Available`;
|
||||
body = `The following ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} you requested is now available!`;
|
||||
body = `Your request for the following ${mediaType} ${
|
||||
is4k ? 'in 4K ' : ''
|
||||
}is now available:`;
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
requestType = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Declined`;
|
||||
body = `Your request for the following ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} was declined:`;
|
||||
body = `Your request for the following ${mediaType} ${
|
||||
is4k ? 'in 4K ' : ''
|
||||
}was declined:`;
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
requestType = `Failed ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
body = `A new request for the following ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} could not be added to ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Sonarr' : 'Radarr'
|
||||
body = `A request for the following ${mediaType} ${
|
||||
is4k ? 'in 4K ' : ''
|
||||
}failed to be added to ${
|
||||
payload.media?.mediaType === MediaType.MOVIE ? 'Radarr' : 'Sonarr'
|
||||
}:`;
|
||||
break;
|
||||
}
|
||||
@@ -127,22 +118,69 @@ class EmailAgent
|
||||
'../../../templates/email/media-request'
|
||||
),
|
||||
message: {
|
||||
to: toEmail,
|
||||
to: recipientEmail,
|
||||
},
|
||||
locals: {
|
||||
requestType,
|
||||
event: payload.event,
|
||||
body,
|
||||
mediaName: payload.subject,
|
||||
mediaPlot: payload.message,
|
||||
mediaExtra: payload.extra ?? [],
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
requestedBy: payload.request?.requestedBy.displayName,
|
||||
requestedBy: payload.request.requestedBy.displayName,
|
||||
actionUrl: applicationUrl
|
||||
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
|
||||
: undefined,
|
||||
applicationUrl,
|
||||
applicationTitle,
|
||||
recipientName,
|
||||
recipientEmail,
|
||||
},
|
||||
};
|
||||
} else if (payload.issue) {
|
||||
const issueType =
|
||||
payload.issue && payload.issue.issueType !== IssueType.OTHER
|
||||
? `${IssueTypeName[payload.issue.issueType].toLowerCase()} issue`
|
||||
: 'issue';
|
||||
|
||||
let body = '';
|
||||
|
||||
switch (type) {
|
||||
case Notification.ISSUE_CREATED:
|
||||
body = `A new ${issueType} has been reported by ${payload.issue.createdBy.displayName} for the ${mediaType} ${payload.subject}:`;
|
||||
break;
|
||||
case Notification.ISSUE_COMMENT:
|
||||
body = `${payload.comment?.user.displayName} commented on the ${issueType} for the ${mediaType} ${payload.subject}:`;
|
||||
break;
|
||||
case Notification.ISSUE_RESOLVED:
|
||||
body = `The ${issueType} for the ${mediaType} ${payload.subject} was marked as resolved by ${payload.issue.modifiedBy?.displayName}!`;
|
||||
break;
|
||||
case Notification.ISSUE_REOPENED:
|
||||
body = `The ${issueType} for the ${mediaType} ${payload.subject} was reopened by ${payload.issue.modifiedBy?.displayName}.`;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
template: path.join(__dirname, '../../../templates/email/media-issue'),
|
||||
message: {
|
||||
to: recipientEmail,
|
||||
},
|
||||
locals: {
|
||||
event: payload.event,
|
||||
body,
|
||||
issueDescription: payload.message,
|
||||
issueComment: payload.comment?.message,
|
||||
mediaName: payload.subject,
|
||||
extra: payload.extra ?? [],
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
actionUrl: applicationUrl
|
||||
? `${applicationUrl}/issues/${payload.issue.id}`
|
||||
: undefined,
|
||||
applicationUrl,
|
||||
applicationTitle,
|
||||
recipientName,
|
||||
recipientEmail,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -155,7 +193,6 @@ class EmailAgent
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
if (payload.notifyUser) {
|
||||
// Send notification to the user who submitted the request
|
||||
if (
|
||||
!payload.notifyUser.settings ||
|
||||
// Check if user has email notifications enabled and fallback to true if undefined
|
||||
@@ -179,7 +216,12 @@ class EmailAgent
|
||||
payload.notifyUser.settings?.pgpKey
|
||||
);
|
||||
await email.send(
|
||||
this.buildMessage(type, payload, payload.notifyUser.email)
|
||||
this.buildMessage(
|
||||
type,
|
||||
payload,
|
||||
payload.notifyUser.email,
|
||||
payload.notifyUser.displayName
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error('Error sending email notification', {
|
||||
@@ -193,8 +235,9 @@ class EmailAgent
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Send notifications to all users with the Manage Requests permission
|
||||
}
|
||||
|
||||
if (payload.notifyAdmin) {
|
||||
const userRepository = getRepository(User);
|
||||
const users = await userRepository.find();
|
||||
|
||||
@@ -202,7 +245,6 @@ class EmailAgent
|
||||
users
|
||||
.filter(
|
||||
(user) =>
|
||||
user.hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
(!user.settings ||
|
||||
// Check if user has email notifications enabled and fallback to true if undefined
|
||||
// since email should default to true
|
||||
@@ -211,9 +253,7 @@ class EmailAgent
|
||||
type
|
||||
) ??
|
||||
true)) &&
|
||||
// Check if it's the user's own auto-approved request
|
||||
(type !== Notification.MEDIA_AUTO_APPROVED ||
|
||||
user.id !== payload.request?.requestedBy.id)
|
||||
shouldSendAdminNotification(type, user, payload)
|
||||
)
|
||||
.map(async (user) => {
|
||||
logger.debug('Sending email notification', {
|
||||
@@ -228,7 +268,9 @@ class EmailAgent
|
||||
this.getSettings(),
|
||||
user.settings?.pgpKey
|
||||
);
|
||||
await email.send(this.buildMessage(type, payload, user.email));
|
||||
await email.send(
|
||||
this.buildMessage(type, payload, user.email, user.displayName)
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error('Error sending email notification', {
|
||||
label: 'Notifications',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { IssueStatus, IssueType } from '../../../constants/issue';
|
||||
import { MediaStatus } from '../../../constants/media';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentLunaSea } from '../../settings';
|
||||
@@ -22,17 +23,17 @@ class LunaSeaAgent
|
||||
private buildPayload(type: Notification, payload: NotificationPayload) {
|
||||
return {
|
||||
notification_type: Notification[type],
|
||||
event: payload.event,
|
||||
subject: payload.subject,
|
||||
message: payload.message,
|
||||
image: payload.image ?? null,
|
||||
email: payload.notifyUser?.email,
|
||||
username: payload.notifyUser?.username,
|
||||
username: payload.notifyUser?.displayName,
|
||||
avatar: payload.notifyUser?.avatar,
|
||||
media: payload.media
|
||||
? {
|
||||
media_type: payload.media.mediaType,
|
||||
tmdbId: payload.media.tmdbId,
|
||||
imdbId: payload.media.imdbId,
|
||||
tvdbId: payload.media.tvdbId,
|
||||
status: MediaStatus[payload.media.status],
|
||||
status4k: MediaStatus[payload.media.status4k],
|
||||
@@ -47,6 +48,24 @@ class LunaSeaAgent
|
||||
requestedBy_avatar: payload.request.requestedBy.avatar,
|
||||
}
|
||||
: null,
|
||||
issue: payload.issue
|
||||
? {
|
||||
issue_id: payload.issue.id,
|
||||
issue_type: IssueType[payload.issue.issueType],
|
||||
issue_status: IssueStatus[payload.issue.status],
|
||||
createdBy_email: payload.issue.createdBy.email,
|
||||
createdBy_username: payload.issue.createdBy.displayName,
|
||||
createdBy_avatar: payload.issue.createdBy.avatar,
|
||||
}
|
||||
: null,
|
||||
comment: payload.comment
|
||||
? {
|
||||
comment_message: payload.comment.message,
|
||||
commentedBy_email: payload.comment.user.email,
|
||||
commentedBy_username: payload.comment.user.displayName,
|
||||
commentedBy_avatar: payload.comment.user.avatar,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
import { getRepository } from 'typeorm';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||
import { User } from '../../../entity/User';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentPushbullet } from '../../settings';
|
||||
import {
|
||||
getSettings,
|
||||
NotificationAgentKey,
|
||||
NotificationAgentPushbullet,
|
||||
} from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
|
||||
interface PushbulletPayload {
|
||||
type: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
@@ -25,109 +36,62 @@ class PushbulletAgent
|
||||
}
|
||||
|
||||
public shouldSend(): boolean {
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (settings.enabled && settings.options.accessToken) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private constructMessageDetails(
|
||||
private getNotificationPayload(
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): {
|
||||
title: string;
|
||||
body: string;
|
||||
} {
|
||||
let messageTitle = '';
|
||||
let message = '';
|
||||
): PushbulletPayload {
|
||||
const title = payload.event
|
||||
? `${payload.event} - ${payload.subject}`
|
||||
: payload.subject;
|
||||
let body = payload.message ?? '';
|
||||
|
||||
const title = payload.subject;
|
||||
const plot = payload.message;
|
||||
const username = payload.request?.requestedBy.displayName;
|
||||
if (payload.request) {
|
||||
body += `\n\nRequested By: ${payload.request.requestedBy.displayName}`;
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
messageTitle = `New ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Pending Approval`;
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Approved`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Processing`;
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Automatically Approved`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Processing`;
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Now Available`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Available`;
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Declined`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Declined`;
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
messageTitle = `Failed ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Failed`;
|
||||
break;
|
||||
case Notification.TEST_NOTIFICATION:
|
||||
messageTitle = 'Test Notification';
|
||||
message += `${plot}`;
|
||||
break;
|
||||
let status = '';
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
status = 'Pending Approval';
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
status = 'Processing';
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
status = 'Available';
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
status = 'Declined';
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
status = 'Failed';
|
||||
break;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
body += `\nRequest Status: ${status}`;
|
||||
}
|
||||
} else if (payload.comment) {
|
||||
body += `\n\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message}`;
|
||||
} else if (payload.issue) {
|
||||
body += `\n\nReported By: ${payload.issue.createdBy.displayName}`;
|
||||
body += `\nIssue Type: ${IssueTypeName[payload.issue.issueType]}`;
|
||||
body += `\nIssue Status: ${
|
||||
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
|
||||
}`;
|
||||
}
|
||||
|
||||
for (const extra of payload.extra ?? []) {
|
||||
message += `\n${extra.name}: ${extra.value}`;
|
||||
body += `\n${extra.name}: ${extra.value}`;
|
||||
}
|
||||
|
||||
return {
|
||||
title: messageTitle,
|
||||
body: message,
|
||||
type: 'note',
|
||||
title,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -136,46 +100,128 @@ class PushbulletAgent
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
const settings = this.getSettings();
|
||||
const endpoint = 'https://api.pushbullet.com/v2/pushes';
|
||||
const notificationPayload = this.getNotificationPayload(type, payload);
|
||||
|
||||
if (!hasNotificationType(type, settings.types ?? 0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.debug('Sending Pushbullet notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
});
|
||||
|
||||
try {
|
||||
const { title, body } = this.constructMessageDetails(type, payload);
|
||||
|
||||
await axios.post(
|
||||
'https://api.pushbullet.com/v2/pushes',
|
||||
{
|
||||
type: 'note',
|
||||
title: title,
|
||||
body: body,
|
||||
} as PushbulletPayload,
|
||||
{
|
||||
headers: {
|
||||
'Access-Token': settings.options.accessToken,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Error sending Pushbullet notification', {
|
||||
// Send system notification
|
||||
if (
|
||||
hasNotificationType(type, settings.types ?? 0) &&
|
||||
settings.enabled &&
|
||||
settings.options.accessToken
|
||||
) {
|
||||
logger.debug('Sending Pushbullet notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
});
|
||||
|
||||
return false;
|
||||
try {
|
||||
await axios.post(endpoint, notificationPayload, {
|
||||
headers: {
|
||||
'Access-Token': settings.options.accessToken,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Error sending Pushbullet notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.notifyUser) {
|
||||
if (
|
||||
payload.notifyUser.settings?.hasNotificationType(
|
||||
NotificationAgentKey.PUSHBULLET,
|
||||
type
|
||||
) &&
|
||||
payload.notifyUser.settings?.pushbulletAccessToken &&
|
||||
payload.notifyUser.settings.pushbulletAccessToken !==
|
||||
settings.options.accessToken
|
||||
) {
|
||||
logger.debug('Sending Pushbullet notification', {
|
||||
label: 'Notifications',
|
||||
recipient: payload.notifyUser.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(endpoint, notificationPayload, {
|
||||
headers: {
|
||||
'Access-Token': payload.notifyUser.settings.pushbulletAccessToken,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Error sending Pushbullet notification', {
|
||||
label: 'Notifications',
|
||||
recipient: payload.notifyUser.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.notifyAdmin) {
|
||||
const userRepository = getRepository(User);
|
||||
const users = await userRepository.find();
|
||||
|
||||
await Promise.all(
|
||||
users
|
||||
.filter(
|
||||
(user) =>
|
||||
user.settings?.hasNotificationType(
|
||||
NotificationAgentKey.PUSHBULLET,
|
||||
type
|
||||
) && shouldSendAdminNotification(type, user, payload)
|
||||
)
|
||||
.map(async (user) => {
|
||||
if (
|
||||
user.settings?.pushbulletAccessToken &&
|
||||
user.settings.pushbulletAccessToken !==
|
||||
settings.options.accessToken
|
||||
) {
|
||||
logger.debug('Sending Pushbullet notification', {
|
||||
label: 'Notifications',
|
||||
recipient: user.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(endpoint, notificationPayload, {
|
||||
headers: {
|
||||
'Access-Token': user.settings.pushbulletAccessToken,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Error sending Pushbullet notification', {
|
||||
label: 'Notifications',
|
||||
recipient: user.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
import { getRepository } from 'typeorm';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||
import { User } from '../../../entity/User';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentPushover } from '../../settings';
|
||||
import {
|
||||
getSettings,
|
||||
NotificationAgentKey,
|
||||
NotificationAgentPushover,
|
||||
} from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
|
||||
interface PushoverPayload {
|
||||
@@ -31,130 +41,89 @@ class PushoverAgent
|
||||
}
|
||||
|
||||
public shouldSend(): boolean {
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (
|
||||
settings.enabled &&
|
||||
settings.options.accessToken &&
|
||||
settings.options.userToken
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private constructMessageDetails(
|
||||
private getNotificationPayload(
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): {
|
||||
title: string;
|
||||
message: string;
|
||||
url: string | undefined;
|
||||
url_title: string | undefined;
|
||||
priority: number;
|
||||
} {
|
||||
const settings = getSettings();
|
||||
let messageTitle = '';
|
||||
let message = '';
|
||||
let url: string | undefined;
|
||||
let url_title: string | undefined;
|
||||
): Partial<PushoverPayload> {
|
||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||
|
||||
const title = payload.event ?? payload.subject;
|
||||
let message = payload.event ? `<b>${payload.subject}</b>` : '';
|
||||
let priority = 0;
|
||||
|
||||
const title = payload.subject;
|
||||
const plot = payload.message;
|
||||
const username = payload.request?.requestedBy.displayName;
|
||||
if (payload.message) {
|
||||
message += `<small>${message ? '\n' : ''}${payload.message}</small>`;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
messageTitle = `New ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `<small>\n${plot}</small>`;
|
||||
}
|
||||
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
|
||||
message += `<small>\n\n<b>Status</b>\nPending Approval</small>`;
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Approved`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `<small>\n${plot}</small>`;
|
||||
}
|
||||
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
|
||||
message += `<small>\n\n<b>Status</b>\nProcessing</small>`;
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Automatically Approved`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `<small>\n${plot}</small>`;
|
||||
}
|
||||
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
|
||||
message += `<small>\n\n<b>Status</b>\nProcessing</small>`;
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Now Available`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `<small>\n${plot}</small>`;
|
||||
}
|
||||
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
|
||||
message += `<small>\n\n<b>Status</b>\nAvailable</small>`;
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Declined`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `<small>\n${plot}</small>`;
|
||||
}
|
||||
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
|
||||
message += `<small>\n\n<b>Status</b>\nDeclined</small>`;
|
||||
if (payload.request) {
|
||||
message += `<small>\n\n<b>Requested By:</b> ${payload.request.requestedBy.displayName}</small>`;
|
||||
|
||||
let status = '';
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
status = 'Pending Approval';
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
status = 'Processing';
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
status = 'Available';
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
status = 'Declined';
|
||||
priority = 1;
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
status = 'Failed';
|
||||
priority = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
message += `<small>\n<b>Request Status:</b> ${status}</small>`;
|
||||
}
|
||||
} else if (payload.comment) {
|
||||
message += `<small>\n\n<b>Comment from ${payload.comment.user.displayName}:</b> ${payload.comment.message}</small>`;
|
||||
} else if (payload.issue) {
|
||||
message += `<small>\n\n<b>Reported By:</b> ${payload.issue.createdBy.displayName}</small>`;
|
||||
message += `<small>\n<b>Issue Type:</b> ${
|
||||
IssueTypeName[payload.issue.issueType]
|
||||
}</small>`;
|
||||
message += `<small>\n<b>Issue Status:</b> ${
|
||||
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
|
||||
}</small>`;
|
||||
|
||||
if (type === Notification.ISSUE_CREATED) {
|
||||
priority = 1;
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
messageTitle = `Failed ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `<small>\n${plot}</small>`;
|
||||
}
|
||||
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
|
||||
message += `<small>\n\n<b>Status</b>\nFailed</small>`;
|
||||
priority = 1;
|
||||
break;
|
||||
case Notification.TEST_NOTIFICATION:
|
||||
messageTitle = 'Test Notification';
|
||||
message += `<small>${plot}</small>`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const extra of payload.extra ?? []) {
|
||||
message += `<small>\n\n<b>${extra.name}</b>\n${extra.value}</small>`;
|
||||
message += `<small>\n<b>${extra.name}:</b> ${extra.value}</small>`;
|
||||
}
|
||||
|
||||
if (settings.main.applicationUrl && payload.media) {
|
||||
url = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
|
||||
url_title = `Open in ${settings.main.applicationTitle}`;
|
||||
}
|
||||
const url = applicationUrl
|
||||
? payload.issue
|
||||
? `${applicationUrl}/issues/${payload.issue.id}`
|
||||
: payload.media
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
: undefined;
|
||||
const url_title = url
|
||||
? `View ${payload.issue ? 'Issue' : 'Media'} in ${applicationTitle}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
title: messageTitle,
|
||||
title,
|
||||
message,
|
||||
url,
|
||||
url_title,
|
||||
priority,
|
||||
html: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -163,45 +132,134 @@ class PushoverAgent
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
const settings = this.getSettings();
|
||||
const endpoint = 'https://api.pushover.net/1/messages.json';
|
||||
const notificationPayload = this.getNotificationPayload(type, payload);
|
||||
|
||||
if (!hasNotificationType(type, settings.types ?? 0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.debug('Sending Pushover notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
});
|
||||
try {
|
||||
const endpoint = 'https://api.pushover.net/1/messages.json';
|
||||
|
||||
const { title, message, url, url_title, priority } =
|
||||
this.constructMessageDetails(type, payload);
|
||||
|
||||
await axios.post(endpoint, {
|
||||
token: settings.options.accessToken,
|
||||
user: settings.options.userToken,
|
||||
title: title,
|
||||
message: message,
|
||||
url: url,
|
||||
url_title: url_title,
|
||||
priority: priority,
|
||||
html: 1,
|
||||
} as PushoverPayload);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Error sending Pushover notification', {
|
||||
// Send system notification
|
||||
if (
|
||||
hasNotificationType(type, settings.types ?? 0) &&
|
||||
settings.enabled &&
|
||||
settings.options.accessToken &&
|
||||
settings.options.userToken
|
||||
) {
|
||||
logger.debug('Sending Pushover notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
});
|
||||
|
||||
return false;
|
||||
try {
|
||||
await axios.post(endpoint, {
|
||||
...notificationPayload,
|
||||
token: settings.options.accessToken,
|
||||
user: settings.options.userToken,
|
||||
} as PushoverPayload);
|
||||
} catch (e) {
|
||||
logger.error('Error sending Pushover notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.notifyUser) {
|
||||
if (
|
||||
payload.notifyUser.settings?.hasNotificationType(
|
||||
NotificationAgentKey.PUSHOVER,
|
||||
type
|
||||
) &&
|
||||
payload.notifyUser.settings?.pushoverApplicationToken &&
|
||||
payload.notifyUser.settings?.pushoverUserKey &&
|
||||
payload.notifyUser.settings.pushoverApplicationToken !==
|
||||
settings.options.accessToken &&
|
||||
payload.notifyUser.settings?.pushoverUserKey !==
|
||||
settings.options.userToken
|
||||
) {
|
||||
logger.debug('Sending Pushover notification', {
|
||||
label: 'Notifications',
|
||||
recipient: payload.notifyUser.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(endpoint, {
|
||||
...notificationPayload,
|
||||
token: payload.notifyUser.settings.pushoverApplicationToken,
|
||||
user: payload.notifyUser.settings.pushoverUserKey,
|
||||
} as PushoverPayload);
|
||||
} catch (e) {
|
||||
logger.error('Error sending Pushover notification', {
|
||||
label: 'Notifications',
|
||||
recipient: payload.notifyUser.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.notifyAdmin) {
|
||||
const userRepository = getRepository(User);
|
||||
const users = await userRepository.find();
|
||||
|
||||
await Promise.all(
|
||||
users
|
||||
.filter(
|
||||
(user) =>
|
||||
user.settings?.hasNotificationType(
|
||||
NotificationAgentKey.PUSHOVER,
|
||||
type
|
||||
) && shouldSendAdminNotification(type, user, payload)
|
||||
)
|
||||
.map(async (user) => {
|
||||
if (
|
||||
user.settings?.pushoverApplicationToken &&
|
||||
user.settings?.pushoverUserKey &&
|
||||
user.settings.pushoverApplicationToken !==
|
||||
settings.options.accessToken &&
|
||||
user.settings.pushoverUserKey !== settings.options.userToken
|
||||
) {
|
||||
logger.debug('Sending Pushover notification', {
|
||||
label: 'Notifications',
|
||||
recipient: user.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(endpoint, {
|
||||
...notificationPayload,
|
||||
token: user.settings.pushoverApplicationToken,
|
||||
user: user.settings.pushoverUserKey,
|
||||
} as PushoverPayload);
|
||||
} catch (e) {
|
||||
logger.error('Error sending Pushover notification', {
|
||||
label: 'Notifications',
|
||||
recipient: user.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentSlack } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
@@ -19,9 +19,10 @@ interface TextItem {
|
||||
interface Element {
|
||||
type: 'button';
|
||||
text?: TextItem;
|
||||
value: string;
|
||||
url: string;
|
||||
action_id: 'button-action';
|
||||
action_id: string;
|
||||
url?: string;
|
||||
value?: string;
|
||||
style?: 'primary' | 'danger';
|
||||
}
|
||||
|
||||
interface EmbedBlock {
|
||||
@@ -34,7 +35,7 @@ interface EmbedBlock {
|
||||
image_url: string;
|
||||
alt_text: string;
|
||||
};
|
||||
elements?: Element[];
|
||||
elements?: (Element | TextItem)[];
|
||||
}
|
||||
|
||||
interface SlackBlockEmbed {
|
||||
@@ -59,9 +60,7 @@ class SlackAgent
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): SlackBlockEmbed {
|
||||
const settings = getSettings();
|
||||
let header = '';
|
||||
let actionUrl: string | undefined;
|
||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||
|
||||
const fields: EmbedField[] = [];
|
||||
|
||||
@@ -70,66 +69,55 @@ class SlackAgent
|
||||
type: 'mrkdwn',
|
||||
text: `*Requested By*\n${payload.request.requestedBy.displayName}`,
|
||||
});
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
header = `New ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
let status = '';
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
status = 'Pending Approval';
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
status = 'Processing';
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
status = 'Available';
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
status = 'Declined';
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
status = 'Failed';
|
||||
break;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
fields.push({
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nPending Approval',
|
||||
text: `*Request Status*\n${status}`,
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
header = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Approved`;
|
||||
fields.push({
|
||||
}
|
||||
} else if (payload.comment) {
|
||||
fields.push({
|
||||
type: 'mrkdwn',
|
||||
text: `*Comment from ${payload.comment.user.displayName}*\n${payload.comment.message}`,
|
||||
});
|
||||
} else if (payload.issue) {
|
||||
fields.push(
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nProcessing',
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
header = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Automatically Approved`;
|
||||
fields.push({
|
||||
text: `*Reported By*\n${payload.issue.createdBy.displayName}`,
|
||||
},
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nProcessing',
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
header = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Now Available`;
|
||||
fields.push({
|
||||
text: `*Issue Type*\n${IssueTypeName[payload.issue.issueType]}`,
|
||||
},
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nAvailable',
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
header = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Declined`;
|
||||
fields.push({
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nDeclined',
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
header = `Failed ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
fields.push({
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nFailed',
|
||||
});
|
||||
break;
|
||||
case Notification.TEST_NOTIFICATION:
|
||||
header = 'Test Notification';
|
||||
break;
|
||||
text: `*Issue Status*\n${
|
||||
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
|
||||
}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
for (const extra of payload.extra ?? []) {
|
||||
@@ -139,30 +127,28 @@ class SlackAgent
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.main.applicationUrl && payload.media) {
|
||||
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
|
||||
}
|
||||
const blocks: EmbedBlock[] = [];
|
||||
|
||||
const blocks: EmbedBlock[] = [
|
||||
{
|
||||
type: 'header',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: header,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (type !== Notification.TEST_NOTIFICATION) {
|
||||
if (payload.event) {
|
||||
blocks.push({
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: `*${payload.subject}*`,
|
||||
},
|
||||
type: 'context',
|
||||
elements: [
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: `*${payload.event}*`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
type: 'header',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: payload.subject,
|
||||
},
|
||||
});
|
||||
|
||||
if (payload.message) {
|
||||
blocks.push({
|
||||
type: 'section',
|
||||
@@ -183,30 +169,31 @@ class SlackAgent
|
||||
if (fields.length > 0) {
|
||||
blocks.push({
|
||||
type: 'section',
|
||||
fields: [
|
||||
...fields,
|
||||
...(payload.extra ?? []).map(
|
||||
(extra): EmbedField => ({
|
||||
type: 'mrkdwn',
|
||||
text: `*${extra.name}*\n${extra.value}`,
|
||||
})
|
||||
),
|
||||
],
|
||||
fields,
|
||||
});
|
||||
}
|
||||
|
||||
if (actionUrl) {
|
||||
const url = applicationUrl
|
||||
? payload.issue
|
||||
? `${applicationUrl}/issues/${payload.issue.id}`
|
||||
: payload.media
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
if (url) {
|
||||
blocks.push({
|
||||
type: 'actions',
|
||||
elements: [
|
||||
{
|
||||
action_id: 'button-action',
|
||||
action_id: 'open-in-overseerr',
|
||||
type: 'button',
|
||||
url: actionUrl,
|
||||
value: 'open_overseerr',
|
||||
url,
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: `Open in ${settings.main.applicationTitle}`,
|
||||
text: `View ${
|
||||
payload.issue ? 'Issue' : 'Media'
|
||||
} in ${applicationTitle}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import axios from 'axios';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||
import { User } from '../../../entity/User';
|
||||
import logger from '../../../logger';
|
||||
import { Permission } from '../../permissions';
|
||||
import {
|
||||
getSettings,
|
||||
NotificationAgentKey,
|
||||
@@ -46,11 +49,7 @@ class TelegramAgent
|
||||
public shouldSend(): boolean {
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (
|
||||
settings.enabled &&
|
||||
settings.options.botAPI &&
|
||||
settings.options.chatId
|
||||
) {
|
||||
if (settings.enabled && settings.options.botAPI) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -61,118 +60,91 @@ class TelegramAgent
|
||||
return text ? text.replace(/[_*[\]()~>#+=|{}.!-]/gi, (x) => '\\' + x) : '';
|
||||
}
|
||||
|
||||
private buildMessage(
|
||||
private getNotificationPayload(
|
||||
type: Notification,
|
||||
payload: NotificationPayload,
|
||||
chatId: string,
|
||||
sendSilently: boolean
|
||||
): TelegramMessagePayload | TelegramPhotoPayload {
|
||||
const settings = getSettings();
|
||||
let message = '';
|
||||
|
||||
const title = this.escapeText(payload.subject);
|
||||
const plot = this.escapeText(payload.message);
|
||||
const user = this.escapeText(payload.request?.requestedBy.displayName);
|
||||
const applicationTitle = this.escapeText(settings.main.applicationTitle);
|
||||
payload: NotificationPayload
|
||||
): Partial<TelegramMessagePayload | TelegramPhotoPayload> {
|
||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||
|
||||
/* eslint-disable no-useless-escape */
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
message += `\*New ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n\*Requested By\*\n${user}`;
|
||||
message += `\n\n\*Status\*\nPending Approval`;
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
message += `\*${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Approved\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n\*Requested By\*\n${user}`;
|
||||
message += `\n\n\*Status\*\nProcessing`;
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
message += `\*${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Automatically Approved\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n\*Requested By\*\n${user}`;
|
||||
message += `\n\n\*Status\*\nProcessing`;
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
message += `\*${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Now Available\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n\*Requested By\*\n${user}`;
|
||||
message += `\n\n\*Status\*\nAvailable`;
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
message += `\*${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Declined\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n\*Requested By\*\n${user}`;
|
||||
message += `\n\n\*Status\*\nDeclined`;
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
message += `\*Failed ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n\*Requested By\*\n${user}`;
|
||||
message += `\n\n\*Status\*\nFailed`;
|
||||
break;
|
||||
case Notification.TEST_NOTIFICATION:
|
||||
message += `\*Test Notification\*`;
|
||||
message += `\n\n${plot}`;
|
||||
break;
|
||||
let message = `\*${this.escapeText(
|
||||
payload.event ? `${payload.event} - ${payload.subject}` : payload.subject
|
||||
)}\*`;
|
||||
if (payload.message) {
|
||||
message += `\n${this.escapeText(payload.message)}`;
|
||||
}
|
||||
|
||||
if (payload.request) {
|
||||
message += `\n\n\*Requested By:\* ${this.escapeText(
|
||||
payload.request?.requestedBy.displayName
|
||||
)}`;
|
||||
|
||||
let status = '';
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
status = 'Pending Approval';
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
status = 'Processing';
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
status = 'Available';
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
status = 'Declined';
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
status = 'Failed';
|
||||
break;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
message += `\n\*Request Status:\* ${status}`;
|
||||
}
|
||||
} else if (payload.comment) {
|
||||
message += `\n\n\*Comment from ${this.escapeText(
|
||||
payload.comment.user.displayName
|
||||
)}:\* ${this.escapeText(payload.comment.message)}`;
|
||||
} else if (payload.issue) {
|
||||
message += `\n\n\*Reported By:\* ${this.escapeText(
|
||||
payload.issue.createdBy.displayName
|
||||
)}`;
|
||||
message += `\n\*Issue Type:\* ${IssueTypeName[payload.issue.issueType]}`;
|
||||
message += `\n\*Issue Status:\* ${
|
||||
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
|
||||
}`;
|
||||
}
|
||||
|
||||
for (const extra of payload.extra ?? []) {
|
||||
message += `\n\n\*${extra.name}\*\n${extra.value}`;
|
||||
message += `\n\*${extra.name}:\* ${extra.value}`;
|
||||
}
|
||||
|
||||
if (settings.main.applicationUrl && payload.media) {
|
||||
const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
|
||||
message += `\n\n\[Open in ${applicationTitle}\]\(${actionUrl}\)`;
|
||||
const url = applicationUrl
|
||||
? payload.issue
|
||||
? `${applicationUrl}/issues/${payload.issue.id}`
|
||||
: payload.media
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
if (url) {
|
||||
message += `\n\n\[View ${
|
||||
payload.issue ? 'Issue' : 'Media'
|
||||
} in ${this.escapeText(applicationTitle)}\]\(${url}\)`;
|
||||
}
|
||||
/* eslint-enable */
|
||||
|
||||
return payload.image
|
||||
? ({
|
||||
? {
|
||||
photo: payload.image,
|
||||
caption: message,
|
||||
parse_mode: 'MarkdownV2',
|
||||
chat_id: chatId,
|
||||
disable_notification: !!sendSilently,
|
||||
} as TelegramPhotoPayload)
|
||||
: ({
|
||||
}
|
||||
: {
|
||||
text: message,
|
||||
parse_mode: 'MarkdownV2',
|
||||
chat_id: chatId,
|
||||
disable_notification: !!sendSilently,
|
||||
} as TelegramMessagePayload);
|
||||
};
|
||||
}
|
||||
|
||||
public async send(
|
||||
@@ -180,13 +152,16 @@ class TelegramAgent
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
const settings = this.getSettings();
|
||||
|
||||
const endpoint = `${this.baseUrl}bot${settings.options.botAPI}/${
|
||||
payload.image ? 'sendPhoto' : 'sendMessage'
|
||||
}`;
|
||||
const notificationPayload = this.getNotificationPayload(type, payload);
|
||||
|
||||
// Send system notification
|
||||
if (hasNotificationType(type, settings.types ?? 0)) {
|
||||
if (
|
||||
hasNotificationType(type, settings.types ?? 0) &&
|
||||
settings.options.chatId
|
||||
) {
|
||||
logger.debug('Sending Telegram notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
@@ -194,15 +169,11 @@ class TelegramAgent
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
endpoint,
|
||||
this.buildMessage(
|
||||
type,
|
||||
payload,
|
||||
settings.options.chatId,
|
||||
settings.options.sendSilently
|
||||
)
|
||||
);
|
||||
await axios.post(endpoint, {
|
||||
...notificationPayload,
|
||||
chat_id: settings.options.chatId,
|
||||
disable_notification: !!settings.options.sendSilently,
|
||||
} as TelegramMessagePayload | TelegramPhotoPayload);
|
||||
} catch (e) {
|
||||
logger.error('Error sending Telegram notification', {
|
||||
label: 'Notifications',
|
||||
@@ -217,14 +188,13 @@ class TelegramAgent
|
||||
}
|
||||
|
||||
if (payload.notifyUser) {
|
||||
// Send notification to the user who submitted the request
|
||||
if (
|
||||
payload.notifyUser.settings?.hasNotificationType(
|
||||
NotificationAgentKey.TELEGRAM,
|
||||
type
|
||||
) &&
|
||||
payload.notifyUser.settings?.telegramChatId &&
|
||||
payload.notifyUser.settings?.telegramChatId !== settings.options.chatId
|
||||
payload.notifyUser.settings.telegramChatId !== settings.options.chatId
|
||||
) {
|
||||
logger.debug('Sending Telegram notification', {
|
||||
label: 'Notifications',
|
||||
@@ -234,15 +204,12 @@ class TelegramAgent
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
endpoint,
|
||||
this.buildMessage(
|
||||
type,
|
||||
payload,
|
||||
payload.notifyUser.settings.telegramChatId,
|
||||
!!payload.notifyUser.settings.telegramSendSilently
|
||||
)
|
||||
);
|
||||
await axios.post(endpoint, {
|
||||
...notificationPayload,
|
||||
chat_id: payload.notifyUser.settings.telegramChatId,
|
||||
disable_notification:
|
||||
!!payload.notifyUser.settings.telegramSendSilently,
|
||||
} as TelegramMessagePayload | TelegramPhotoPayload);
|
||||
} catch (e) {
|
||||
logger.error('Error sending Telegram notification', {
|
||||
label: 'Notifications',
|
||||
@@ -256,8 +223,9 @@ class TelegramAgent
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Send notifications to all users with the Manage Requests permission
|
||||
}
|
||||
|
||||
if (payload.notifyAdmin) {
|
||||
const userRepository = getRepository(User);
|
||||
const users = await userRepository.find();
|
||||
|
||||
@@ -265,14 +233,10 @@ class TelegramAgent
|
||||
users
|
||||
.filter(
|
||||
(user) =>
|
||||
user.hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
user.settings?.hasNotificationType(
|
||||
NotificationAgentKey.TELEGRAM,
|
||||
type
|
||||
) &&
|
||||
// Check if it's the user's own auto-approved request
|
||||
(type !== Notification.MEDIA_AUTO_APPROVED ||
|
||||
user.id !== payload.request?.requestedBy.id)
|
||||
) && shouldSendAdminNotification(type, user, payload)
|
||||
)
|
||||
.map(async (user) => {
|
||||
if (
|
||||
@@ -287,15 +251,11 @@ class TelegramAgent
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
endpoint,
|
||||
this.buildMessage(
|
||||
type,
|
||||
payload,
|
||||
user.settings.telegramChatId,
|
||||
!!user.settings?.telegramSendSilently
|
||||
)
|
||||
);
|
||||
await axios.post(endpoint, {
|
||||
...notificationPayload,
|
||||
chat_id: user.settings.telegramChatId,
|
||||
disable_notification: !!user.settings?.telegramSendSilently,
|
||||
} as TelegramMessagePayload | TelegramPhotoPayload);
|
||||
} catch (e) {
|
||||
logger.error('Error sending Telegram notification', {
|
||||
label: 'Notifications',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import { get } from 'lodash';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { IssueStatus, IssueType } from '../../../constants/issue';
|
||||
import { MediaStatus } from '../../../constants/media';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentWebhook } from '../../settings';
|
||||
@@ -13,6 +14,7 @@ type KeyMapFunction = (
|
||||
|
||||
const KeyMap: Record<string, string | KeyMapFunction> = {
|
||||
notification_type: (_payload, type) => Notification[type],
|
||||
event: 'event',
|
||||
subject: 'subject',
|
||||
message: 'message',
|
||||
image: 'image',
|
||||
@@ -22,13 +24,12 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
|
||||
notifyuser_settings_discordId: 'notifyUser.settings.discordId',
|
||||
notifyuser_settings_telegramChatId: 'notifyUser.settings.telegramChatId',
|
||||
media_tmdbid: 'media.tmdbId',
|
||||
media_imdbid: 'media.imdbId',
|
||||
media_tvdbid: 'media.tvdbId',
|
||||
media_type: 'media.mediaType',
|
||||
media_status: (payload) =>
|
||||
payload.media?.status ? MediaStatus[payload.media?.status] : '',
|
||||
payload.media ? MediaStatus[payload.media.status] : '',
|
||||
media_status4k: (payload) =>
|
||||
payload.media?.status ? MediaStatus[payload.media?.status4k] : '',
|
||||
payload.media ? MediaStatus[payload.media.status4k] : '',
|
||||
request_id: 'request.id',
|
||||
requestedBy_username: 'request.requestedBy.displayName',
|
||||
requestedBy_email: 'request.requestedBy.email',
|
||||
@@ -36,6 +37,22 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
|
||||
requestedBy_settings_discordId: 'request.requestedBy.settings.discordId',
|
||||
requestedBy_settings_telegramChatId:
|
||||
'request.requestedBy.settings.telegramChatId',
|
||||
issue_id: 'issue.id',
|
||||
issue_type: (payload) =>
|
||||
payload.issue ? IssueType[payload.issue.issueType] : '',
|
||||
issue_status: (payload) =>
|
||||
payload.issue ? IssueStatus[payload.issue.status] : '',
|
||||
reportedBy_username: 'issue.createdBy.displayName',
|
||||
reportedBy_email: 'issue.createdBy.email',
|
||||
reportedBy_avatar: 'issue.createdBy.avatar',
|
||||
reportedBy_settings_discordId: 'issue.createdBy.settings.discordId',
|
||||
reportedBy_settings_telegramChatId: 'issue.createdBy.settings.telegramChatId',
|
||||
comment_message: 'comment.message',
|
||||
commentedBy_username: 'comment.user.displayName',
|
||||
commentedBy_email: 'comment.user.email',
|
||||
commentedBy_avatar: 'comment.user.avatar',
|
||||
commentedBy_settings_discordId: 'comment.user.settings.discordId',
|
||||
commentedBy_settings_telegramChatId: 'comment.user.settings.telegramChatId',
|
||||
};
|
||||
|
||||
class WebhookAgent
|
||||
@@ -78,6 +95,22 @@ class WebhookAgent
|
||||
}
|
||||
delete finalPayload[key];
|
||||
key = 'request';
|
||||
} else if (key === '{{issue}}') {
|
||||
if (payload.issue) {
|
||||
finalPayload.issue = finalPayload[key];
|
||||
} else {
|
||||
finalPayload.issue = null;
|
||||
}
|
||||
delete finalPayload[key];
|
||||
key = 'issue';
|
||||
} else if (key === '{{comment}}') {
|
||||
if (payload.comment) {
|
||||
finalPayload.comment = finalPayload[key];
|
||||
} else {
|
||||
finalPayload.comment = null;
|
||||
}
|
||||
delete finalPayload[key];
|
||||
key = 'comment';
|
||||
}
|
||||
|
||||
if (typeof finalPayload[key] === 'string') {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { getRepository } from 'typeorm';
|
||||
import webpush from 'web-push';
|
||||
import { Notification } from '..';
|
||||
import { Notification, shouldSendAdminNotification } from '..';
|
||||
import { IssueType, IssueTypeName } from '../../../constants/issue';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
import { User } from '../../../entity/User';
|
||||
import { UserPushSubscription } from '../../../entity/UserPushSubscription';
|
||||
import logger from '../../../logger';
|
||||
import { Permission } from '../../permissions';
|
||||
import {
|
||||
getSettings,
|
||||
NotificationAgentConfig,
|
||||
@@ -15,12 +15,11 @@ import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
|
||||
interface PushNotificationPayload {
|
||||
notificationType: string;
|
||||
mediaType?: 'movie' | 'tv';
|
||||
tmdbId?: number;
|
||||
subject: string;
|
||||
message?: string;
|
||||
image?: string;
|
||||
actionUrl?: string;
|
||||
actionUrlTitle?: string;
|
||||
requestId?: number;
|
||||
}
|
||||
|
||||
@@ -42,97 +41,92 @@ class WebPushAgent
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): PushNotificationPayload {
|
||||
const mediaType = payload.media
|
||||
? payload.media.mediaType === MediaType.MOVIE
|
||||
? 'movie'
|
||||
: 'series'
|
||||
: undefined;
|
||||
const is4k = payload.request?.is4k;
|
||||
|
||||
const issueType = payload.issue
|
||||
? payload.issue.issueType !== IssueType.OTHER
|
||||
? `${IssueTypeName[payload.issue.issueType].toLowerCase()} issue`
|
||||
: 'issue'
|
||||
: undefined;
|
||||
|
||||
let message: string | undefined;
|
||||
switch (type) {
|
||||
case Notification.NONE:
|
||||
case Notification.TEST_NOTIFICATION:
|
||||
message = payload.message;
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
message = `Your ${
|
||||
is4k ? '4K ' : ''
|
||||
}${mediaType} request has been approved.`;
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
message = `Automatically approved a new ${
|
||||
is4k ? '4K ' : ''
|
||||
}${mediaType} request from ${
|
||||
payload.request?.requestedBy.displayName
|
||||
}.`;
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
message = `Your ${
|
||||
is4k ? '4K ' : ''
|
||||
}${mediaType} request is now available!`;
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
message = `Your ${is4k ? '4K ' : ''}${mediaType} request was declined.`;
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
message = `Failed to process ${is4k ? '4K ' : ''}${mediaType} request.`;
|
||||
break;
|
||||
case Notification.MEDIA_PENDING:
|
||||
message = `Approval required for a new ${
|
||||
is4k ? '4K ' : ''
|
||||
}${mediaType} request from ${
|
||||
payload.request?.requestedBy.displayName
|
||||
}.`;
|
||||
break;
|
||||
case Notification.ISSUE_CREATED:
|
||||
message = `A new ${issueType} was reported by ${payload.issue?.createdBy.displayName}.`;
|
||||
break;
|
||||
case Notification.ISSUE_COMMENT:
|
||||
message = `${payload.comment?.user.displayName} commented on the ${issueType}.`;
|
||||
break;
|
||||
case Notification.ISSUE_RESOLVED:
|
||||
message = `The ${issueType} was marked as resolved by ${payload.issue?.modifiedBy?.displayName}!`;
|
||||
break;
|
||||
case Notification.ISSUE_REOPENED:
|
||||
message = `The ${issueType} was reopened by ${payload.issue?.modifiedBy?.displayName}.`;
|
||||
break;
|
||||
default:
|
||||
return {
|
||||
notificationType: Notification[type],
|
||||
subject: 'Unknown',
|
||||
};
|
||||
case Notification.TEST_NOTIFICATION:
|
||||
return {
|
||||
notificationType: Notification[type],
|
||||
subject: payload.subject,
|
||||
message: payload.message,
|
||||
};
|
||||
case Notification.MEDIA_APPROVED:
|
||||
return {
|
||||
notificationType: Notification[type],
|
||||
subject: payload.subject,
|
||||
message: `Your ${
|
||||
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
|
||||
} request has been approved.`,
|
||||
image: payload.image,
|
||||
mediaType: payload.media?.mediaType,
|
||||
tmdbId: payload.media?.tmdbId,
|
||||
requestId: payload.request?.id,
|
||||
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||
};
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
return {
|
||||
notificationType: Notification[type],
|
||||
subject: payload.subject,
|
||||
message: `Automatically approved a new ${
|
||||
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
|
||||
} request from ${payload.request?.requestedBy.displayName}.`,
|
||||
image: payload.image,
|
||||
mediaType: payload.media?.mediaType,
|
||||
tmdbId: payload.media?.tmdbId,
|
||||
requestId: payload.request?.id,
|
||||
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||
};
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
return {
|
||||
notificationType: Notification[type],
|
||||
subject: payload.subject,
|
||||
message: `Your ${
|
||||
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
|
||||
} request is now available!`,
|
||||
image: payload.image,
|
||||
mediaType: payload.media?.mediaType,
|
||||
tmdbId: payload.media?.tmdbId,
|
||||
requestId: payload.request?.id,
|
||||
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||
};
|
||||
case Notification.MEDIA_DECLINED:
|
||||
return {
|
||||
notificationType: Notification[type],
|
||||
subject: payload.subject,
|
||||
message: `Your ${
|
||||
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
|
||||
} request was declined.`,
|
||||
image: payload.image,
|
||||
mediaType: payload.media?.mediaType,
|
||||
tmdbId: payload.media?.tmdbId,
|
||||
requestId: payload.request?.id,
|
||||
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||
};
|
||||
case Notification.MEDIA_FAILED:
|
||||
return {
|
||||
notificationType: Notification[type],
|
||||
subject: payload.subject,
|
||||
message: `Failed to process ${
|
||||
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
|
||||
} request.`,
|
||||
image: payload.image,
|
||||
mediaType: payload.media?.mediaType,
|
||||
tmdbId: payload.media?.tmdbId,
|
||||
requestId: payload.request?.id,
|
||||
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||
};
|
||||
case Notification.MEDIA_PENDING:
|
||||
return {
|
||||
notificationType: Notification[type],
|
||||
subject: payload.subject,
|
||||
message: `Approval required for new ${
|
||||
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
|
||||
} request from ${payload.request?.requestedBy.displayName}.`,
|
||||
image: payload.image,
|
||||
mediaType: payload.media?.mediaType,
|
||||
tmdbId: payload.media?.tmdbId,
|
||||
requestId: payload.request?.id,
|
||||
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||
};
|
||||
}
|
||||
|
||||
const actionUrl = payload.issue
|
||||
? `/issues/${payload.issue.id}`
|
||||
: payload.media
|
||||
? `/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined;
|
||||
|
||||
const actionUrlTitle = actionUrl
|
||||
? `View ${payload.issue ? 'Issue' : 'Media'}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
notificationType: Notification[type],
|
||||
subject: payload.subject,
|
||||
message,
|
||||
image: payload.image,
|
||||
requestId: payload.request?.id,
|
||||
actionUrl,
|
||||
actionUrlTitle,
|
||||
};
|
||||
}
|
||||
|
||||
public shouldSend(): boolean {
|
||||
@@ -151,7 +145,7 @@ class WebPushAgent
|
||||
const userPushSubRepository = getRepository(UserPushSubscription);
|
||||
const settings = getSettings();
|
||||
|
||||
let pushSubs: UserPushSubscription[] = [];
|
||||
const pushSubs: UserPushSubscription[] = [];
|
||||
|
||||
const mainUser = await userRepository.findOne({ where: { id: 1 } });
|
||||
|
||||
@@ -169,13 +163,14 @@ class WebPushAgent
|
||||
where: { user: payload.notifyUser.id },
|
||||
});
|
||||
|
||||
pushSubs = notifySubs;
|
||||
} else if (!payload.notifyUser) {
|
||||
pushSubs.push(...notifySubs);
|
||||
}
|
||||
|
||||
if (payload.notifyAdmin) {
|
||||
const users = await userRepository.find();
|
||||
|
||||
const manageUsers = users.filter(
|
||||
(user) =>
|
||||
user.hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
// Check if user has webpush notifications enabled and fallback to true if undefined
|
||||
// since web push should default to true
|
||||
(user.settings?.hasNotificationType(
|
||||
@@ -183,9 +178,7 @@ class WebPushAgent
|
||||
type
|
||||
) ??
|
||||
true) &&
|
||||
// Check if it's the user's own auto-approved request
|
||||
(type !== Notification.MEDIA_AUTO_APPROVED ||
|
||||
user.id !== payload.request?.requestedBy.id)
|
||||
shouldSendAdminNotification(type, user, payload)
|
||||
);
|
||||
|
||||
const allSubs = await userPushSubRepository
|
||||
@@ -196,7 +189,7 @@ class WebPushAgent
|
||||
})
|
||||
.getMany();
|
||||
|
||||
pushSubs = allSubs;
|
||||
pushSubs.push(...allSubs);
|
||||
}
|
||||
|
||||
if (mainUser && pushSubs.length > 0) {
|
||||
@@ -206,6 +199,11 @@ class WebPushAgent
|
||||
settings.vapidPrivate
|
||||
);
|
||||
|
||||
const notificationPayload = Buffer.from(
|
||||
JSON.stringify(this.getNotificationPayload(type, payload)),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
pushSubs.map(async (sub) => {
|
||||
logger.debug('Sending web push notification', {
|
||||
@@ -224,10 +222,7 @@ class WebPushAgent
|
||||
p256dh: sub.p256dh,
|
||||
},
|
||||
},
|
||||
Buffer.from(
|
||||
JSON.stringify(this.getNotificationPayload(type, payload)),
|
||||
'utf-8'
|
||||
)
|
||||
notificationPayload
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { User } from '../../entity/User';
|
||||
import logger from '../../logger';
|
||||
import { Permission } from '../permissions';
|
||||
import type { NotificationAgent, NotificationPayload } from './agents/agent';
|
||||
|
||||
export enum Notification {
|
||||
@@ -10,6 +12,10 @@ export enum Notification {
|
||||
TEST_NOTIFICATION = 32,
|
||||
MEDIA_DECLINED = 64,
|
||||
MEDIA_AUTO_APPROVED = 128,
|
||||
ISSUE_CREATED = 256,
|
||||
ISSUE_COMMENT = 512,
|
||||
ISSUE_RESOLVED = 1024,
|
||||
ISSUE_REOPENED = 2048,
|
||||
}
|
||||
|
||||
export const hasNotificationType = (
|
||||
@@ -38,6 +44,50 @@ export const hasNotificationType = (
|
||||
return !!(value & total);
|
||||
};
|
||||
|
||||
export const getAdminPermission = (type: Notification): Permission => {
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
case Notification.MEDIA_APPROVED:
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
case Notification.MEDIA_FAILED:
|
||||
case Notification.MEDIA_DECLINED:
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
return Permission.MANAGE_REQUESTS;
|
||||
case Notification.ISSUE_CREATED:
|
||||
case Notification.ISSUE_COMMENT:
|
||||
case Notification.ISSUE_RESOLVED:
|
||||
case Notification.ISSUE_REOPENED:
|
||||
return Permission.MANAGE_ISSUES;
|
||||
default:
|
||||
return Permission.ADMIN;
|
||||
}
|
||||
};
|
||||
|
||||
export const shouldSendAdminNotification = (
|
||||
type: Notification,
|
||||
user: User,
|
||||
payload: NotificationPayload
|
||||
): boolean => {
|
||||
return (
|
||||
user.id !== payload.notifyUser?.id &&
|
||||
user.hasPermission(getAdminPermission(type)) &&
|
||||
// Check if the user submitted this request (on behalf of themself OR another user)
|
||||
(type !== Notification.MEDIA_AUTO_APPROVED ||
|
||||
user.id !==
|
||||
(payload.request?.modifiedBy ?? payload.request?.requestedBy)?.id) &&
|
||||
// Check if the user created this issue
|
||||
(type !== Notification.ISSUE_CREATED ||
|
||||
user.id !== payload.issue?.createdBy.id) &&
|
||||
// Check if the user submitted this issue comment
|
||||
(type !== Notification.ISSUE_COMMENT ||
|
||||
user.id !== payload.comment?.user.id) &&
|
||||
// Check if the user resolved/reopened this issue
|
||||
((type !== Notification.ISSUE_RESOLVED &&
|
||||
type !== Notification.ISSUE_REOPENED) ||
|
||||
user.id !== payload.issue?.modifiedBy?.id)
|
||||
);
|
||||
};
|
||||
|
||||
class NotificationManager {
|
||||
private activeAgents: NotificationAgent[] = [];
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ export enum Permission {
|
||||
AUTO_APPROVE_4K_TV = 131072,
|
||||
REQUEST_MOVIE = 262144,
|
||||
REQUEST_TV = 524288,
|
||||
MANAGE_ISSUES = 1048576,
|
||||
VIEW_ISSUES = 2097152,
|
||||
CREATE_ISSUES = 4194304,
|
||||
}
|
||||
|
||||
export interface PermissionCheckOptions {
|
||||
|
||||
@@ -55,6 +55,7 @@ class BaseScanner<T> {
|
||||
private updateRate;
|
||||
protected progress = 0;
|
||||
protected items: T[] = [];
|
||||
protected totalSize?: number = 0;
|
||||
protected scannerName: string;
|
||||
protected enable4kMovie = false;
|
||||
protected enable4kShow = false;
|
||||
@@ -609,6 +610,14 @@ class BaseScanner<T> {
|
||||
): void {
|
||||
logger[level](message, { label: this.scannerName, ...optional });
|
||||
}
|
||||
|
||||
get protectedUpdateRate(): number {
|
||||
return this.updateRate;
|
||||
}
|
||||
|
||||
get protectedBundleSize(): number {
|
||||
return this.bundleSize;
|
||||
}
|
||||
}
|
||||
|
||||
export default BaseScanner;
|
||||
|
||||
@@ -4,6 +4,7 @@ import animeList from '../../../api/animelist';
|
||||
import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../../api/plexapi';
|
||||
import { TmdbTvDetails } from '../../../api/themoviedb/interfaces';
|
||||
import { User } from '../../../entity/User';
|
||||
import cacheManager from '../../cache';
|
||||
import { getSettings, Library } from '../../settings';
|
||||
import BaseScanner, {
|
||||
MediaIds,
|
||||
@@ -38,7 +39,7 @@ class PlexScanner
|
||||
private isRecentOnly = false;
|
||||
|
||||
public constructor(isRecentOnly = false) {
|
||||
super('Plex Scan');
|
||||
super('Plex Scan', { bundleSize: 50 });
|
||||
this.isRecentOnly = isRecentOnly;
|
||||
}
|
||||
|
||||
@@ -46,7 +47,7 @@ class PlexScanner
|
||||
return {
|
||||
running: this.running,
|
||||
progress: this.progress,
|
||||
total: this.items.length,
|
||||
total: this.totalSize ?? 0,
|
||||
currentLibrary: this.currentLibrary,
|
||||
libraries: this.libraries,
|
||||
};
|
||||
@@ -82,10 +83,17 @@ class PlexScanner
|
||||
this.currentLibrary = library;
|
||||
this.log(
|
||||
`Beginning to process recently added for library: ${library.name}`,
|
||||
'info'
|
||||
'info',
|
||||
{ lastScan: library.lastScan }
|
||||
);
|
||||
const libraryItems = await this.plexClient.getRecentlyAdded(
|
||||
library.id
|
||||
library.id,
|
||||
library.lastScan
|
||||
? {
|
||||
// We remove 10 minutes from the last scan as a buffer
|
||||
addedAt: library.lastScan - 1000 * 60 * 10,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
|
||||
// Bundle items up by rating keys
|
||||
@@ -104,13 +112,26 @@ class PlexScanner
|
||||
});
|
||||
|
||||
await this.loop(this.processItem.bind(this), { sessionId });
|
||||
|
||||
// After run completes, update last scan time
|
||||
const newLibraries = settings.plex.libraries.map((lib) => {
|
||||
if (lib.id === library.id) {
|
||||
return {
|
||||
...lib,
|
||||
lastScan: Date.now(),
|
||||
};
|
||||
}
|
||||
return lib;
|
||||
});
|
||||
|
||||
settings.plex.libraries = newLibraries;
|
||||
settings.save();
|
||||
}
|
||||
} else {
|
||||
for (const library of this.libraries) {
|
||||
this.currentLibrary = library;
|
||||
this.log(`Beginning to process library: ${library.name}`, 'info');
|
||||
this.items = await this.plexClient.getLibraryContents(library.id);
|
||||
await this.loop(this.processItem.bind(this), { sessionId });
|
||||
await this.paginateLibrary(library, { sessionId });
|
||||
}
|
||||
}
|
||||
this.log(
|
||||
@@ -126,6 +147,52 @@ class PlexScanner
|
||||
}
|
||||
}
|
||||
|
||||
private async paginateLibrary(
|
||||
library: Library,
|
||||
{ start = 0, sessionId }: { start?: number; sessionId: string }
|
||||
) {
|
||||
if (!this.running) {
|
||||
throw new Error('Sync was aborted.');
|
||||
}
|
||||
|
||||
if (this.sessionId !== sessionId) {
|
||||
throw new Error('New session was started. Old session aborted.');
|
||||
}
|
||||
|
||||
const response = await this.plexClient.getLibraryContents(library.id, {
|
||||
size: this.protectedBundleSize,
|
||||
offset: start,
|
||||
});
|
||||
|
||||
this.progress = start;
|
||||
this.totalSize = response.totalSize;
|
||||
|
||||
if (response.items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
response.items.map(async (item) => {
|
||||
await this.processItem(item);
|
||||
})
|
||||
);
|
||||
|
||||
if (response.items.length < this.protectedBundleSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
setTimeout(() => {
|
||||
this.paginateLibrary(library, {
|
||||
start: start + this.protectedBundleSize,
|
||||
sessionId,
|
||||
})
|
||||
.then(() => resolve())
|
||||
.catch((e) => reject(new Error(e.message)));
|
||||
}, this.protectedUpdateRate)
|
||||
);
|
||||
}
|
||||
|
||||
private async processItem(plexitem: PlexLibraryItem) {
|
||||
try {
|
||||
if (plexitem.type === 'movie') {
|
||||
@@ -147,9 +214,8 @@ class PlexScanner
|
||||
|
||||
private async processPlexMovie(plexitem: PlexLibraryItem) {
|
||||
const mediaIds = await this.getMediaIds(plexitem);
|
||||
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
|
||||
|
||||
const has4k = metadata.Media.some(
|
||||
const has4k = plexitem.Media.some(
|
||||
(media) => media.videoResolution === '4k'
|
||||
);
|
||||
|
||||
@@ -263,10 +329,25 @@ class PlexScanner
|
||||
}
|
||||
|
||||
private async getMediaIds(plexitem: PlexLibraryItem): Promise<MediaIds> {
|
||||
const mediaIds: Partial<MediaIds> = {};
|
||||
let mediaIds: Partial<MediaIds> = {};
|
||||
// Check if item is using new plex movie/tv agent
|
||||
if (plexitem.guid.match(plexRegex)) {
|
||||
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
|
||||
const guidCache = cacheManager.getCache('plexguid');
|
||||
|
||||
const cachedGuids = guidCache.data.get<MediaIds>(plexitem.ratingKey);
|
||||
|
||||
if (cachedGuids) {
|
||||
this.log('GUIDs are cached. Skipping metadata request.', 'debug', {
|
||||
mediaIds: cachedGuids,
|
||||
title: plexitem.title,
|
||||
});
|
||||
mediaIds = cachedGuids;
|
||||
}
|
||||
|
||||
const metadata =
|
||||
plexitem.Guid && plexitem.Guid.length > 0
|
||||
? plexitem
|
||||
: await this.plexClient.getMetadata(plexitem.ratingKey);
|
||||
|
||||
// If there is no Guid field at all, then we bail
|
||||
if (!metadata.Guid) {
|
||||
@@ -295,6 +376,10 @@ class PlexScanner
|
||||
});
|
||||
mediaIds.tmdbId = tmdbMovie.id;
|
||||
}
|
||||
|
||||
// Cache GUIDs
|
||||
guidCache.data.set(plexitem.ratingKey, mediaIds);
|
||||
|
||||
// Check if the agent is IMDb
|
||||
} else if (plexitem.guid.match(imdbRegex)) {
|
||||
const imdbMatch = plexitem.guid.match(imdbRegex);
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface Library {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
type: 'show' | 'movie';
|
||||
lastScan?: number;
|
||||
}
|
||||
|
||||
export interface Region {
|
||||
@@ -213,6 +215,18 @@ interface NotificationSettings {
|
||||
agents: NotificationAgents;
|
||||
}
|
||||
|
||||
interface JobSettings {
|
||||
schedule: string;
|
||||
}
|
||||
|
||||
export type JobId =
|
||||
| 'plex-recently-added-scan'
|
||||
| 'plex-full-scan'
|
||||
| 'radarr-scan'
|
||||
| 'sonarr-scan'
|
||||
| 'download-sync'
|
||||
| 'download-sync-reset';
|
||||
|
||||
interface AllSettings {
|
||||
clientId: string;
|
||||
vapidPublic: string;
|
||||
@@ -223,6 +237,7 @@ interface AllSettings {
|
||||
sonarr: SonarrSettings[];
|
||||
public: PublicSettings;
|
||||
notifications: NotificationSettings;
|
||||
jobs: Record<JobId, JobSettings>;
|
||||
}
|
||||
|
||||
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
|
||||
@@ -335,7 +350,7 @@ class Settings {
|
||||
options: {
|
||||
webhookUrl: '',
|
||||
jsonPayload:
|
||||
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJzdWJqZWN0XCI6IFwie3tzdWJqZWN0fX1cIixcbiAgICBcIm1lc3NhZ2VcIjogXCJ7e21lc3NhZ2V9fVwiLFxuICAgIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgICBcImVtYWlsXCI6IFwie3tub3RpZnl1c2VyX2VtYWlsfX1cIixcbiAgICBcInVzZXJuYW1lXCI6IFwie3tub3RpZnl1c2VyX3VzZXJuYW1lfX1cIixcbiAgICBcImF2YXRhclwiOiBcInt7bm90aWZ5dXNlcl9hdmF0YXJ9fVwiLFxuICAgIFwie3ttZWRpYX19XCI6IHtcbiAgICAgICAgXCJtZWRpYV90eXBlXCI6IFwie3ttZWRpYV90eXBlfX1cIixcbiAgICAgICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgICAgIFwiaW1kYklkXCI6IFwie3ttZWRpYV9pbWRiaWR9fVwiLFxuICAgICAgICBcInR2ZGJJZFwiOiBcInt7bWVkaWFfdHZkYmlkfX1cIixcbiAgICAgICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgICAgIFwic3RhdHVzNGtcIjogXCJ7e21lZGlhX3N0YXR1czRrfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW10sXG4gICAgXCJ7e3JlcXVlc3R9fVwiOiB7XG4gICAgICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfZW1haWxcIjogXCJ7e3JlcXVlc3RlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV91c2VybmFtZVwiOiBcInt7cmVxdWVzdGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIlxuICAgIH1cbn0i',
|
||||
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
|
||||
},
|
||||
},
|
||||
webpush: {
|
||||
@@ -344,6 +359,26 @@ class Settings {
|
||||
},
|
||||
},
|
||||
},
|
||||
jobs: {
|
||||
'plex-recently-added-scan': {
|
||||
schedule: '0 */5 * * * *',
|
||||
},
|
||||
'plex-full-scan': {
|
||||
schedule: '0 0 3 * * *',
|
||||
},
|
||||
'radarr-scan': {
|
||||
schedule: '0 0 4 * * *',
|
||||
},
|
||||
'sonarr-scan': {
|
||||
schedule: '0 30 4 * * *',
|
||||
},
|
||||
'download-sync': {
|
||||
schedule: '0 * * * * *',
|
||||
},
|
||||
'download-sync-reset': {
|
||||
schedule: '0 0 1 * * *',
|
||||
},
|
||||
},
|
||||
};
|
||||
if (initialSettings) {
|
||||
this.data = merge(this.data, initialSettings);
|
||||
@@ -426,6 +461,14 @@ class Settings {
|
||||
this.data.notifications = data;
|
||||
}
|
||||
|
||||
get jobs(): Record<JobId, JobSettings> {
|
||||
return this.data.jobs;
|
||||
}
|
||||
|
||||
set jobs(data: Record<JobId, JobSettings>) {
|
||||
this.data.jobs = data;
|
||||
}
|
||||
|
||||
get clientId(): string {
|
||||
if (!this.data.clientId) {
|
||||
this.data.clientId = randomUUID();
|
||||
|
||||
55
server/migration/1634904083966-AddIssues.ts
Normal file
55
server/migration/1634904083966-AddIssues.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddIssues1634904083966 implements MigrationInterface {
|
||||
name = 'AddIssues1634904083966';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "issue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "problemSeason" integer NOT NULL DEFAULT (0), "problemEpisode" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "createdById" integer, "modifiedById" integer)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "issueId" integer)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_issue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "problemSeason" integer NOT NULL DEFAULT (0), "problemEpisode" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "createdById" integer, "modifiedById" integer, CONSTRAINT "FK_276e20d053f3cff1645803c95d8" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_10b17b49d1ee77e7184216001e0" FOREIGN KEY ("createdById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_da88a1019c850d1a7b143ca02e5" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_issue"("id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById") SELECT "id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById" FROM "issue"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "issue"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_issue" RENAME TO "issue"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "issueId" integer, CONSTRAINT "FK_707b033c2d0653f75213614789d" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_180710fead1c94ca499c57a7d42" FOREIGN KEY ("issueId") REFERENCES "issue" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_issue_comment"("id", "message", "createdAt", "updatedAt", "userId", "issueId") SELECT "id", "message", "createdAt", "updatedAt", "userId", "issueId" FROM "issue_comment"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "issue_comment"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_issue_comment" RENAME TO "issue_comment"`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "issue_comment" RENAME TO "temporary_issue_comment"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "issueId" integer)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "issue_comment"("id", "message", "createdAt", "updatedAt", "userId", "issueId") SELECT "id", "message", "createdAt", "updatedAt", "userId", "issueId" FROM "temporary_issue_comment"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_issue_comment"`);
|
||||
await queryRunner.query(`ALTER TABLE "issue" RENAME TO "temporary_issue"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "issue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "problemSeason" integer NOT NULL DEFAULT (0), "problemEpisode" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "createdById" integer, "modifiedById" integer)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "issue"("id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById") SELECT "id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById" FROM "temporary_issue"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_issue"`);
|
||||
await queryRunner.query(`DROP TABLE "issue_comment"`);
|
||||
await queryRunner.query(`DROP TABLE "issue"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddPushbulletPushoverUserSettings1635079863457
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddPushbulletPushoverUserSettings1635079863457';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "user_settings"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user_settings"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "temporary_user_settings"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sortBy } from 'lodash';
|
||||
import type { TmdbCollection } from '../api/themoviedb/interfaces';
|
||||
import { MediaType } from '../constants/media';
|
||||
import Media from '../entity/Media';
|
||||
@@ -21,7 +22,7 @@ export const mapCollection = (
|
||||
overview: collection.overview,
|
||||
posterPath: collection.poster_path,
|
||||
backdropPath: collection.backdrop_path,
|
||||
parts: collection.parts.map((part) =>
|
||||
parts: sortBy(collection.parts, 'release_date').map((part) =>
|
||||
mapMovieResult(
|
||||
part,
|
||||
media?.find(
|
||||
|
||||
@@ -90,6 +90,10 @@ export interface TvDetails {
|
||||
overview: string;
|
||||
popularity: number;
|
||||
productionCompanies: ProductionCompany[];
|
||||
productionCountries: {
|
||||
iso_3166_1: string;
|
||||
name: string;
|
||||
}[];
|
||||
spokenLanguages: SpokenLanguage[];
|
||||
seasons: Season[];
|
||||
status: string;
|
||||
@@ -187,6 +191,7 @@ export const mapTvDetails = (
|
||||
originCountry: company.origin_country,
|
||||
logoPath: company.logo_path,
|
||||
})),
|
||||
productionCountries: show.production_countries,
|
||||
contentRatings: show.content_ratings,
|
||||
spokenLanguages: show.spoken_languages.map((language) => ({
|
||||
englishName: language.english_name,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { mapMovieResult, mapPersonResult, mapTvResult } from '../models/Search';
|
||||
import { mapNetwork } from '../models/Tv';
|
||||
import { isMovie, isPerson } from '../utils/typeHelpers';
|
||||
|
||||
const createTmdbWithRegionLanaguage = (user?: User): TheMovieDb => {
|
||||
export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
|
||||
const settings = getSettings();
|
||||
|
||||
const region =
|
||||
@@ -38,7 +38,7 @@ const createTmdbWithRegionLanaguage = (user?: User): TheMovieDb => {
|
||||
const discoverRoutes = Router();
|
||||
|
||||
discoverRoutes.get('/movies', async (req, res) => {
|
||||
const tmdb = createTmdbWithRegionLanaguage(req.user);
|
||||
const tmdb = createTmdbWithRegionLanguage(req.user);
|
||||
|
||||
const data = await tmdb.getDiscoverMovies({
|
||||
page: Number(req.query.page),
|
||||
@@ -69,7 +69,7 @@ discoverRoutes.get('/movies', async (req, res) => {
|
||||
discoverRoutes.get<{ language: string }>(
|
||||
'/movies/language/:language',
|
||||
async (req, res, next) => {
|
||||
const tmdb = createTmdbWithRegionLanaguage(req.user);
|
||||
const tmdb = createTmdbWithRegionLanguage(req.user);
|
||||
|
||||
const languages = await tmdb.getLanguages();
|
||||
|
||||
@@ -112,7 +112,7 @@ discoverRoutes.get<{ language: string }>(
|
||||
discoverRoutes.get<{ genreId: string }>(
|
||||
'/movies/genre/:genreId',
|
||||
async (req, res, next) => {
|
||||
const tmdb = createTmdbWithRegionLanaguage(req.user);
|
||||
const tmdb = createTmdbWithRegionLanguage(req.user);
|
||||
|
||||
const genres = await tmdb.getMovieGenres({
|
||||
language: req.locale ?? (req.query.language as string),
|
||||
@@ -194,7 +194,7 @@ discoverRoutes.get<{ studioId: string }>(
|
||||
);
|
||||
|
||||
discoverRoutes.get('/movies/upcoming', async (req, res) => {
|
||||
const tmdb = createTmdbWithRegionLanaguage(req.user);
|
||||
const tmdb = createTmdbWithRegionLanguage(req.user);
|
||||
|
||||
const now = new Date();
|
||||
const offset = now.getTimezoneOffset();
|
||||
@@ -228,7 +228,7 @@ discoverRoutes.get('/movies/upcoming', async (req, res) => {
|
||||
});
|
||||
|
||||
discoverRoutes.get('/tv', async (req, res) => {
|
||||
const tmdb = createTmdbWithRegionLanaguage(req.user);
|
||||
const tmdb = createTmdbWithRegionLanguage(req.user);
|
||||
|
||||
const data = await tmdb.getDiscoverTv({
|
||||
page: Number(req.query.page),
|
||||
@@ -259,7 +259,7 @@ discoverRoutes.get('/tv', async (req, res) => {
|
||||
discoverRoutes.get<{ language: string }>(
|
||||
'/tv/language/:language',
|
||||
async (req, res, next) => {
|
||||
const tmdb = createTmdbWithRegionLanaguage(req.user);
|
||||
const tmdb = createTmdbWithRegionLanguage(req.user);
|
||||
|
||||
const languages = await tmdb.getLanguages();
|
||||
|
||||
@@ -301,7 +301,7 @@ discoverRoutes.get<{ language: string }>(
|
||||
discoverRoutes.get<{ genreId: string }>(
|
||||
'/tv/genre/:genreId',
|
||||
async (req, res, next) => {
|
||||
const tmdb = createTmdbWithRegionLanaguage(req.user);
|
||||
const tmdb = createTmdbWithRegionLanguage(req.user);
|
||||
|
||||
const genres = await tmdb.getTvGenres({
|
||||
language: req.locale ?? (req.query.language as string),
|
||||
@@ -382,7 +382,7 @@ discoverRoutes.get<{ networkId: string }>(
|
||||
);
|
||||
|
||||
discoverRoutes.get('/tv/upcoming', async (req, res) => {
|
||||
const tmdb = createTmdbWithRegionLanaguage(req.user);
|
||||
const tmdb = createTmdbWithRegionLanguage(req.user);
|
||||
|
||||
const now = new Date();
|
||||
const offset = now.getTimezoneOffset();
|
||||
@@ -416,7 +416,7 @@ discoverRoutes.get('/tv/upcoming', async (req, res) => {
|
||||
});
|
||||
|
||||
discoverRoutes.get('/trending', async (req, res) => {
|
||||
const tmdb = createTmdbWithRegionLanaguage(req.user);
|
||||
const tmdb = createTmdbWithRegionLanguage(req.user);
|
||||
|
||||
const data = await tmdb.getAllTrending({
|
||||
page: Number(req.query.page),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import GithubAPI from '../api/github';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import { TmdbMovieResult, TmdbTvResult } from '../api/themoviedb/interfaces';
|
||||
import { StatusResponse } from '../interfaces/api/settingsInterfaces';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import { getSettings } from '../lib/settings';
|
||||
@@ -9,9 +10,12 @@ import { mapProductionCompany } from '../models/Movie';
|
||||
import { mapNetwork } from '../models/Tv';
|
||||
import { appDataPath, appDataStatus } from '../utils/appDataVolume';
|
||||
import { getAppVersion, getCommitTag } from '../utils/appVersion';
|
||||
import { isPerson } from '../utils/typeHelpers';
|
||||
import authRoutes from './auth';
|
||||
import collectionRoutes from './collection';
|
||||
import discoverRoutes from './discover';
|
||||
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
|
||||
import issueRoutes from './issue';
|
||||
import issueCommentRoutes from './issueComment';
|
||||
import mediaRoutes from './media';
|
||||
import movieRoutes from './movie';
|
||||
import personRoutes from './person';
|
||||
@@ -106,6 +110,8 @@ router.use('/media', isAuthenticated(), mediaRoutes);
|
||||
router.use('/person', isAuthenticated(), personRoutes);
|
||||
router.use('/collection', isAuthenticated(), collectionRoutes);
|
||||
router.use('/service', isAuthenticated(), serviceRoutes);
|
||||
router.use('/issue', isAuthenticated(), issueRoutes);
|
||||
router.use('/issueComment', isAuthenticated(), issueCommentRoutes);
|
||||
router.use('/auth', authRoutes);
|
||||
|
||||
router.get('/regions', isAuthenticated(), async (req, res) => {
|
||||
@@ -160,6 +166,28 @@ router.get('/genres/tv', isAuthenticated(), async (req, res) => {
|
||||
return res.status(200).json(genres);
|
||||
});
|
||||
|
||||
router.get('/backdrops', async (req, res) => {
|
||||
const tmdb = createTmdbWithRegionLanguage();
|
||||
|
||||
const data = (
|
||||
await tmdb.getAllTrending({
|
||||
page: 1,
|
||||
timeWindow: 'week',
|
||||
})
|
||||
).results.filter((result) => !isPerson(result)) as (
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
)[];
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.json(
|
||||
data
|
||||
.map((result) => result.backdrop_path)
|
||||
.filter((backdropPath) => !!backdropPath)
|
||||
);
|
||||
});
|
||||
|
||||
router.get('/', (_req, res) => {
|
||||
return res.status(200).json({
|
||||
api: 'Overseerr API',
|
||||
|
||||
332
server/routes/issue.ts
Normal file
332
server/routes/issue.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { Router } from 'express';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { IssueStatus } from '../constants/issue';
|
||||
import Issue from '../entity/Issue';
|
||||
import IssueComment from '../entity/IssueComment';
|
||||
import Media from '../entity/Media';
|
||||
import { IssueResultsResponse } from '../interfaces/api/issueInterfaces';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import logger from '../logger';
|
||||
import { isAuthenticated } from '../middleware/auth';
|
||||
|
||||
const issueRoutes = Router();
|
||||
|
||||
issueRoutes.get<Record<string, string>, IssueResultsResponse>(
|
||||
'/',
|
||||
isAuthenticated(
|
||||
[
|
||||
Permission.MANAGE_ISSUES,
|
||||
Permission.VIEW_ISSUES,
|
||||
Permission.CREATE_ISSUES,
|
||||
],
|
||||
{ type: 'or' }
|
||||
),
|
||||
async (req, res, next) => {
|
||||
const pageSize = req.query.take ? Number(req.query.take) : 10;
|
||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||
const createdBy = req.query.createdBy ? Number(req.query.createdBy) : null;
|
||||
|
||||
let sortFilter: string;
|
||||
|
||||
switch (req.query.sort) {
|
||||
case 'modified':
|
||||
sortFilter = 'issue.updatedAt';
|
||||
break;
|
||||
default:
|
||||
sortFilter = 'issue.createdAt';
|
||||
}
|
||||
|
||||
let statusFilter: IssueStatus[];
|
||||
|
||||
switch (req.query.filter) {
|
||||
case 'open':
|
||||
statusFilter = [IssueStatus.OPEN];
|
||||
break;
|
||||
case 'resolved':
|
||||
statusFilter = [IssueStatus.RESOLVED];
|
||||
break;
|
||||
default:
|
||||
statusFilter = [IssueStatus.OPEN, IssueStatus.RESOLVED];
|
||||
}
|
||||
|
||||
let query = getRepository(Issue)
|
||||
.createQueryBuilder('issue')
|
||||
.leftJoinAndSelect('issue.createdBy', 'createdBy')
|
||||
.leftJoinAndSelect('issue.media', 'media')
|
||||
.leftJoinAndSelect('issue.modifiedBy', 'modifiedBy')
|
||||
.where('issue.status IN (:...issueStatus)', {
|
||||
issueStatus: statusFilter,
|
||||
});
|
||||
|
||||
if (
|
||||
!req.user?.hasPermission(
|
||||
[Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES],
|
||||
{ type: 'or' }
|
||||
)
|
||||
) {
|
||||
if (createdBy && createdBy !== req.user?.id) {
|
||||
return next({
|
||||
status: 403,
|
||||
message:
|
||||
'You do not have permission to view issues reported by other users',
|
||||
});
|
||||
}
|
||||
query = query.andWhere('createdBy.id = :id', { id: req.user?.id });
|
||||
} else if (createdBy) {
|
||||
query = query.andWhere('createdBy.id = :id', { id: createdBy });
|
||||
}
|
||||
|
||||
const [issues, issueCount] = await query
|
||||
.orderBy(sortFilter, 'DESC')
|
||||
.take(pageSize)
|
||||
.skip(skip)
|
||||
.getManyAndCount();
|
||||
|
||||
return res.status(200).json({
|
||||
pageInfo: {
|
||||
pages: Math.ceil(issueCount / pageSize),
|
||||
pageSize,
|
||||
results: issueCount,
|
||||
page: Math.ceil(skip / pageSize) + 1,
|
||||
},
|
||||
results: issues,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
issueRoutes.post<
|
||||
Record<string, string>,
|
||||
Issue,
|
||||
{
|
||||
message: string;
|
||||
mediaId: number;
|
||||
issueType: number;
|
||||
problemSeason: number;
|
||||
problemEpisode: number;
|
||||
}
|
||||
>(
|
||||
'/',
|
||||
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
|
||||
type: 'or',
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
// Satisfy typescript here. User is set, we assure you!
|
||||
if (!req.user) {
|
||||
return next({ status: 500, message: 'User missing from request.' });
|
||||
}
|
||||
|
||||
const issueRepository = getRepository(Issue);
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: req.body.mediaId },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
return next({ status: 404, message: 'Media does not exist.' });
|
||||
}
|
||||
|
||||
const issue = new Issue({
|
||||
createdBy: req.user,
|
||||
issueType: req.body.issueType,
|
||||
problemSeason: req.body.problemSeason,
|
||||
problemEpisode: req.body.problemEpisode,
|
||||
media,
|
||||
comments: [
|
||||
new IssueComment({
|
||||
user: req.user,
|
||||
message: req.body.message,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const newIssue = await issueRepository.save(issue);
|
||||
|
||||
return res.status(200).json(newIssue);
|
||||
}
|
||||
);
|
||||
|
||||
issueRoutes.get<{ issueId: string }>(
|
||||
'/:issueId',
|
||||
isAuthenticated(
|
||||
[
|
||||
Permission.MANAGE_ISSUES,
|
||||
Permission.VIEW_ISSUES,
|
||||
Permission.CREATE_ISSUES,
|
||||
],
|
||||
{ type: 'or' }
|
||||
),
|
||||
async (req, res, next) => {
|
||||
const issueRepository = getRepository(Issue);
|
||||
// Satisfy typescript here. User is set, we assure you!
|
||||
if (!req.user) {
|
||||
return next({ status: 500, message: 'User missing from request.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const issue = await issueRepository
|
||||
.createQueryBuilder('issue')
|
||||
.leftJoinAndSelect('issue.comments', 'comments')
|
||||
.leftJoinAndSelect('issue.createdBy', 'createdBy')
|
||||
.leftJoinAndSelect('comments.user', 'user')
|
||||
.leftJoinAndSelect('issue.media', 'media')
|
||||
.where('issue.id = :issueId', { issueId: Number(req.params.issueId) })
|
||||
.getOneOrFail();
|
||||
|
||||
if (
|
||||
issue.createdBy.id !== req.user.id &&
|
||||
!req.user.hasPermission(
|
||||
[Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES],
|
||||
{ type: 'or' }
|
||||
)
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You do not have permission to view this issue.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json(issue);
|
||||
} catch (e) {
|
||||
logger.debug('Failed to retrieve issue.', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
next({ status: 500, message: 'Issue not found.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
issueRoutes.post<{ issueId: string }, Issue, { message: string }>(
|
||||
'/:issueId/comment',
|
||||
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
|
||||
type: 'or',
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
const issueRepository = getRepository(Issue);
|
||||
// Satisfy typescript here. User is set, we assure you!
|
||||
if (!req.user) {
|
||||
return next({ status: 500, message: 'User missing from request.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const issue = await issueRepository.findOneOrFail({
|
||||
where: { id: Number(req.params.issueId) },
|
||||
});
|
||||
|
||||
if (
|
||||
issue.createdBy.id !== req.user.id &&
|
||||
!req.user.hasPermission(Permission.MANAGE_ISSUES)
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You do not have permission to comment on this issue.',
|
||||
});
|
||||
}
|
||||
|
||||
const comment = new IssueComment({
|
||||
message: req.body.message,
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
issue.comments = [...issue.comments, comment];
|
||||
|
||||
await issueRepository.save(issue);
|
||||
|
||||
return res.status(200).json(issue);
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong creating an issue comment.', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
next({ status: 500, message: 'Issue not found.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
issueRoutes.post<{ issueId: string; status: string }, Issue>(
|
||||
'/:issueId/:status',
|
||||
isAuthenticated(Permission.MANAGE_ISSUES),
|
||||
async (req, res, next) => {
|
||||
const issueRepository = getRepository(Issue);
|
||||
// Satisfy typescript here. User is set, we assure you!
|
||||
if (!req.user) {
|
||||
return next({ status: 500, message: 'User missing from request.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const issue = await issueRepository.findOneOrFail({
|
||||
where: { id: Number(req.params.issueId) },
|
||||
});
|
||||
|
||||
let newStatus: IssueStatus | undefined;
|
||||
|
||||
switch (req.params.status) {
|
||||
case 'resolved':
|
||||
newStatus = IssueStatus.RESOLVED;
|
||||
break;
|
||||
case 'open':
|
||||
newStatus = IssueStatus.OPEN;
|
||||
}
|
||||
|
||||
if (!newStatus) {
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'You must provide a valid status',
|
||||
});
|
||||
}
|
||||
|
||||
issue.status = newStatus;
|
||||
issue.modifiedBy = req.user;
|
||||
|
||||
await issueRepository.save(issue);
|
||||
|
||||
return res.status(200).json(issue);
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong creating an issue comment.', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
next({ status: 500, message: 'Issue not found.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
issueRoutes.delete(
|
||||
'/:issueId',
|
||||
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
|
||||
type: 'or',
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
const issueRepository = getRepository(Issue);
|
||||
|
||||
try {
|
||||
const issue = await issueRepository.findOneOrFail({
|
||||
where: { id: Number(req.params.issueId) },
|
||||
relations: ['createdBy'],
|
||||
});
|
||||
|
||||
if (
|
||||
!req.user?.hasPermission(Permission.MANAGE_ISSUES) &&
|
||||
(issue.createdBy.id !== req.user?.id || issue.comments.length > 1)
|
||||
) {
|
||||
return next({
|
||||
status: 401,
|
||||
message: 'You do not have permission to delete this issue.',
|
||||
});
|
||||
}
|
||||
|
||||
await issueRepository.remove(issue);
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong deleting an issue.', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
next({ status: 404, message: 'Issue not found.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default issueRoutes;
|
||||
129
server/routes/issueComment.ts
Normal file
129
server/routes/issueComment.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Router } from 'express';
|
||||
import { getRepository } from 'typeorm';
|
||||
import IssueComment from '../entity/IssueComment';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import logger from '../logger';
|
||||
import { isAuthenticated } from '../middleware/auth';
|
||||
|
||||
const issueCommentRoutes = Router();
|
||||
|
||||
issueCommentRoutes.get<{ commentId: string }, IssueComment>(
|
||||
'/:commentId',
|
||||
isAuthenticated(
|
||||
[
|
||||
Permission.MANAGE_ISSUES,
|
||||
Permission.VIEW_ISSUES,
|
||||
Permission.CREATE_ISSUES,
|
||||
],
|
||||
{
|
||||
type: 'or',
|
||||
}
|
||||
),
|
||||
async (req, res, next) => {
|
||||
const issueCommentRepository = getRepository(IssueComment);
|
||||
|
||||
try {
|
||||
const comment = await issueCommentRepository.findOneOrFail({
|
||||
where: { id: Number(req.params.commentId) },
|
||||
});
|
||||
|
||||
if (
|
||||
!req.user?.hasPermission(
|
||||
[Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES],
|
||||
{ type: 'or' }
|
||||
) &&
|
||||
comment.user.id !== req.user?.id
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You do not have permission to view this comment.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json(comment);
|
||||
} catch (e) {
|
||||
logger.debug('Request for unknown issue comment failed', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
next({ status: 404, message: 'Issue comment not found.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
issueCommentRoutes.put<
|
||||
{ commentId: string },
|
||||
IssueComment,
|
||||
{ message: string }
|
||||
>(
|
||||
'/:commentId',
|
||||
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
|
||||
type: 'or',
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
const issueCommentRepository = getRepository(IssueComment);
|
||||
|
||||
try {
|
||||
const comment = await issueCommentRepository.findOneOrFail({
|
||||
where: { id: Number(req.params.commentId) },
|
||||
});
|
||||
|
||||
if (comment.user.id !== req.user?.id) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You can only edit your own comments.',
|
||||
});
|
||||
}
|
||||
|
||||
comment.message = req.body.message;
|
||||
|
||||
await issueCommentRepository.save(comment);
|
||||
|
||||
return res.status(200).json(comment);
|
||||
} catch (e) {
|
||||
logger.debug('Put request for issue comment failed', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
next({ status: 404, message: 'Issue comment not found.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
issueCommentRoutes.delete<{ commentId: string }, IssueComment>(
|
||||
'/:commentId',
|
||||
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
|
||||
type: 'or',
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
const issueCommentRepository = getRepository(IssueComment);
|
||||
|
||||
try {
|
||||
const comment = await issueCommentRepository.findOneOrFail({
|
||||
where: { id: Number(req.params.commentId) },
|
||||
});
|
||||
|
||||
if (
|
||||
!req.user?.hasPermission([Permission.MANAGE_ISSUES], { type: 'or' }) &&
|
||||
comment.user.id !== req.user?.id
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You do not have permission to delete this comment.',
|
||||
});
|
||||
}
|
||||
|
||||
await issueCommentRepository.remove(comment);
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
logger.debug('Delete request for issue comment failed', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
next({ status: 404, message: 'Issue comment not found.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default issueCommentRoutes;
|
||||
@@ -13,131 +13,134 @@ import { isAuthenticated } from '../middleware/auth';
|
||||
|
||||
const requestRoutes = Router();
|
||||
|
||||
requestRoutes.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const pageSize = req.query.take ? Number(req.query.take) : 10;
|
||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||
const requestedBy = req.query.requestedBy
|
||||
? Number(req.query.requestedBy)
|
||||
: null;
|
||||
requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
||||
'/',
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const pageSize = req.query.take ? Number(req.query.take) : 10;
|
||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||
const requestedBy = req.query.requestedBy
|
||||
? Number(req.query.requestedBy)
|
||||
: null;
|
||||
|
||||
let statusFilter: MediaRequestStatus[];
|
||||
let statusFilter: MediaRequestStatus[];
|
||||
|
||||
switch (req.query.filter) {
|
||||
case 'approved':
|
||||
case 'processing':
|
||||
case 'available':
|
||||
statusFilter = [MediaRequestStatus.APPROVED];
|
||||
break;
|
||||
case 'pending':
|
||||
statusFilter = [MediaRequestStatus.PENDING];
|
||||
break;
|
||||
case 'unavailable':
|
||||
statusFilter = [
|
||||
MediaRequestStatus.PENDING,
|
||||
MediaRequestStatus.APPROVED,
|
||||
];
|
||||
break;
|
||||
default:
|
||||
statusFilter = [
|
||||
MediaRequestStatus.PENDING,
|
||||
MediaRequestStatus.APPROVED,
|
||||
MediaRequestStatus.DECLINED,
|
||||
];
|
||||
}
|
||||
switch (req.query.filter) {
|
||||
case 'approved':
|
||||
case 'processing':
|
||||
case 'available':
|
||||
statusFilter = [MediaRequestStatus.APPROVED];
|
||||
break;
|
||||
case 'pending':
|
||||
statusFilter = [MediaRequestStatus.PENDING];
|
||||
break;
|
||||
case 'unavailable':
|
||||
statusFilter = [
|
||||
MediaRequestStatus.PENDING,
|
||||
MediaRequestStatus.APPROVED,
|
||||
];
|
||||
break;
|
||||
default:
|
||||
statusFilter = [
|
||||
MediaRequestStatus.PENDING,
|
||||
MediaRequestStatus.APPROVED,
|
||||
MediaRequestStatus.DECLINED,
|
||||
];
|
||||
}
|
||||
|
||||
let mediaStatusFilter: MediaStatus[];
|
||||
let mediaStatusFilter: MediaStatus[];
|
||||
|
||||
switch (req.query.filter) {
|
||||
case 'available':
|
||||
mediaStatusFilter = [MediaStatus.AVAILABLE];
|
||||
break;
|
||||
case 'processing':
|
||||
case 'unavailable':
|
||||
mediaStatusFilter = [
|
||||
MediaStatus.UNKNOWN,
|
||||
MediaStatus.PENDING,
|
||||
MediaStatus.PROCESSING,
|
||||
MediaStatus.PARTIALLY_AVAILABLE,
|
||||
];
|
||||
break;
|
||||
default:
|
||||
mediaStatusFilter = [
|
||||
MediaStatus.UNKNOWN,
|
||||
MediaStatus.PENDING,
|
||||
MediaStatus.PROCESSING,
|
||||
MediaStatus.PARTIALLY_AVAILABLE,
|
||||
MediaStatus.AVAILABLE,
|
||||
];
|
||||
}
|
||||
switch (req.query.filter) {
|
||||
case 'available':
|
||||
mediaStatusFilter = [MediaStatus.AVAILABLE];
|
||||
break;
|
||||
case 'processing':
|
||||
case 'unavailable':
|
||||
mediaStatusFilter = [
|
||||
MediaStatus.UNKNOWN,
|
||||
MediaStatus.PENDING,
|
||||
MediaStatus.PROCESSING,
|
||||
MediaStatus.PARTIALLY_AVAILABLE,
|
||||
];
|
||||
break;
|
||||
default:
|
||||
mediaStatusFilter = [
|
||||
MediaStatus.UNKNOWN,
|
||||
MediaStatus.PENDING,
|
||||
MediaStatus.PROCESSING,
|
||||
MediaStatus.PARTIALLY_AVAILABLE,
|
||||
MediaStatus.AVAILABLE,
|
||||
];
|
||||
}
|
||||
|
||||
let sortFilter: string;
|
||||
let sortFilter: string;
|
||||
|
||||
switch (req.query.sort) {
|
||||
case 'modified':
|
||||
sortFilter = 'request.updatedAt';
|
||||
break;
|
||||
default:
|
||||
sortFilter = 'request.id';
|
||||
}
|
||||
switch (req.query.sort) {
|
||||
case 'modified':
|
||||
sortFilter = 'request.updatedAt';
|
||||
break;
|
||||
default:
|
||||
sortFilter = 'request.id';
|
||||
}
|
||||
|
||||
let query = getRepository(MediaRequest)
|
||||
.createQueryBuilder('request')
|
||||
.leftJoinAndSelect('request.media', 'media')
|
||||
.leftJoinAndSelect('request.seasons', 'seasons')
|
||||
.leftJoinAndSelect('request.modifiedBy', 'modifiedBy')
|
||||
.leftJoinAndSelect('request.requestedBy', 'requestedBy')
|
||||
.where('request.status IN (:...requestStatus)', {
|
||||
requestStatus: statusFilter,
|
||||
})
|
||||
.andWhere(
|
||||
'((request.is4k = 0 AND media.status IN (:...mediaStatus)) OR (request.is4k = 1 AND media.status4k IN (:...mediaStatus)))',
|
||||
{
|
||||
mediaStatus: mediaStatusFilter,
|
||||
let query = getRepository(MediaRequest)
|
||||
.createQueryBuilder('request')
|
||||
.leftJoinAndSelect('request.media', 'media')
|
||||
.leftJoinAndSelect('request.seasons', 'seasons')
|
||||
.leftJoinAndSelect('request.modifiedBy', 'modifiedBy')
|
||||
.leftJoinAndSelect('request.requestedBy', 'requestedBy')
|
||||
.where('request.status IN (:...requestStatus)', {
|
||||
requestStatus: statusFilter,
|
||||
})
|
||||
.andWhere(
|
||||
'((request.is4k = 0 AND media.status IN (:...mediaStatus)) OR (request.is4k = 1 AND media.status4k IN (:...mediaStatus)))',
|
||||
{
|
||||
mediaStatus: mediaStatusFilter,
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
!req.user?.hasPermission(
|
||||
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
|
||||
{ type: 'or' }
|
||||
)
|
||||
) {
|
||||
if (requestedBy && requestedBy !== req.user?.id) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: "You do not have permission to view this user's requests.",
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
!req.user?.hasPermission(
|
||||
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
|
||||
{ type: 'or' }
|
||||
)
|
||||
) {
|
||||
if (requestedBy && requestedBy !== req.user?.id) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: "You do not have permission to view this user's requests.",
|
||||
query = query.andWhere('requestedBy.id = :id', {
|
||||
id: req.user?.id,
|
||||
});
|
||||
} else if (requestedBy) {
|
||||
query = query.andWhere('requestedBy.id = :id', {
|
||||
id: requestedBy,
|
||||
});
|
||||
}
|
||||
|
||||
query = query.andWhere('requestedBy.id = :id', {
|
||||
id: req.user?.id,
|
||||
});
|
||||
} else if (requestedBy) {
|
||||
query = query.andWhere('requestedBy.id = :id', {
|
||||
id: requestedBy,
|
||||
const [requests, requestCount] = await query
|
||||
.orderBy(sortFilter, 'DESC')
|
||||
.take(pageSize)
|
||||
.skip(skip)
|
||||
.getManyAndCount();
|
||||
|
||||
return res.status(200).json({
|
||||
pageInfo: {
|
||||
pages: Math.ceil(requestCount / pageSize),
|
||||
pageSize,
|
||||
results: requestCount,
|
||||
page: Math.ceil(skip / pageSize) + 1,
|
||||
},
|
||||
results: requests,
|
||||
});
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
|
||||
const [requests, requestCount] = await query
|
||||
.orderBy(sortFilter, 'DESC')
|
||||
.take(pageSize)
|
||||
.skip(skip)
|
||||
.getManyAndCount();
|
||||
|
||||
return res.status(200).json({
|
||||
pageInfo: {
|
||||
pages: Math.ceil(requestCount / pageSize),
|
||||
pageSize,
|
||||
results: requestCount,
|
||||
page: Math.ceil(skip / pageSize) + 1,
|
||||
},
|
||||
results: requests,
|
||||
} as RequestResultsResponse);
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
requestRoutes.post('/', async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
@@ -497,9 +500,26 @@ requestRoutes.get('/:requestId', async (req, res, next) => {
|
||||
relations: ['requestedBy', 'modifiedBy'],
|
||||
});
|
||||
|
||||
if (
|
||||
request.requestedBy.id !== req.user?.id &&
|
||||
!req.user?.hasPermission(
|
||||
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
|
||||
{ type: 'or' }
|
||||
)
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You do not have permission to view this request.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json(request);
|
||||
} catch (e) {
|
||||
next({ status: 404, message: 'Request not found' });
|
||||
logger.debug('Failed to retrieve request.', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
next({ status: 404, message: 'Request not found.' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -529,11 +549,11 @@ requestRoutes.put<{ requestId: string }>(
|
||||
});
|
||||
}
|
||||
|
||||
let requestUser = req.user;
|
||||
let requestUser = request.requestedBy;
|
||||
|
||||
if (
|
||||
req.body.userId &&
|
||||
req.body.userId !== req.user?.id &&
|
||||
req.body.userId !== request.requestedBy.id &&
|
||||
!req.user?.hasPermission([
|
||||
Permission.MANAGE_USERS,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
@@ -665,7 +685,10 @@ requestRoutes.delete('/:requestId', async (req, res, next) => {
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
logger.error(e.message);
|
||||
logger.error('Something went wrong deleting a request.', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
next({ status: 404, message: 'Request not found.' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import fs from 'fs';
|
||||
import { merge, omit } from 'lodash';
|
||||
import { rescheduleJob } from 'node-schedule';
|
||||
import path from 'path';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { URL } from 'url';
|
||||
@@ -20,7 +21,7 @@ import { scheduledJobs } from '../../job/schedule';
|
||||
import cacheManager, { AvailableCacheIds } from '../../lib/cache';
|
||||
import { Permission } from '../../lib/permissions';
|
||||
import { plexFullScanner } from '../../lib/scanners/plex';
|
||||
import { getSettings, Library, MainSettings } from '../../lib/settings';
|
||||
import { getSettings, MainSettings } from '../../lib/settings';
|
||||
import logger from '../../logger';
|
||||
import { isAuthenticated } from '../../middleware/auth';
|
||||
import { getAppVersion } from '../../utils/appVersion';
|
||||
@@ -49,7 +50,7 @@ settingsRoutes.get('/main', (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
|
||||
if (!req.user) {
|
||||
return next({ status: 500, message: 'User missing from request' });
|
||||
return next({ status: 400, message: 'User missing from request' });
|
||||
}
|
||||
|
||||
res.status(200).json(filteredMainSettings(req.user, settings.main));
|
||||
@@ -197,26 +198,7 @@ settingsRoutes.get('/plex/library', async (req, res) => {
|
||||
});
|
||||
const plexapi = new PlexAPI({ plexToken: admin.plexToken });
|
||||
|
||||
const libraries = await plexapi.getLibraries();
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
settings.plex.libraries = newLibraries;
|
||||
await plexapi.syncLibraries();
|
||||
}
|
||||
|
||||
const enabledLibraries = req.query.enable
|
||||
@@ -276,25 +258,29 @@ settingsRoutes.get(
|
||||
try {
|
||||
fs.readFileSync(logFile)
|
||||
.toString()
|
||||
.split('\n')
|
||||
.split(/(?=\n\d{4}-\d{2})/g)
|
||||
.forEach((line) => {
|
||||
if (!line.length) return;
|
||||
|
||||
const timestamp = line.match(new RegExp(/^.{24}/)) || [];
|
||||
const level = line.match(new RegExp(/\s\[\w+\]/)) || [];
|
||||
const label = line.match(new RegExp(/\]\[.+?\]/)) || [];
|
||||
const message = line.match(new RegExp(/:\s([^{}]+)({.*})?/)) || [];
|
||||
const jsonRegexp = new RegExp(
|
||||
/[{[]{1}([,:{}[\]0-9.\-+Eaeflnr-u \n\r\t]|"[^"\n]*?")+[}\]]{1}/
|
||||
);
|
||||
|
||||
if (level.length && filter.includes(level[0].slice(2, -1))) {
|
||||
const timestamp = line.match(new RegExp(/.{24}/)) || [];
|
||||
const level = line.match(new RegExp(/(?<=.{24}\s\[).+?(?=\])/)) || [];
|
||||
const label =
|
||||
line.match(new RegExp(/(?<=.{24}\s\[.+\]\[).+?(?=\])/)) || [];
|
||||
const message =
|
||||
line.match(new RegExp(/(?<=\[.+\]:\s)[\s\S][^\r]+/)) || [];
|
||||
const data = message[0].match(jsonRegexp) || [];
|
||||
|
||||
if (level.length && filter.includes(level[0])) {
|
||||
logs.push({
|
||||
timestamp: timestamp[0],
|
||||
level: level.length ? level[0].slice(2, -1) : '',
|
||||
label: label.length ? label[0].slice(2, -1) : '',
|
||||
message: message.length && message[1] ? message[1] : '',
|
||||
data:
|
||||
message.length && message[2]
|
||||
? JSON.parse(message[2])
|
||||
: undefined,
|
||||
level: level[0],
|
||||
label: label[0],
|
||||
message: message[0].replace(jsonRegexp, ''),
|
||||
data: data.length ? JSON.parse(data[0]) : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -329,6 +315,7 @@ settingsRoutes.get('/jobs', (_req, res) => {
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
type: job.type,
|
||||
interval: job.interval,
|
||||
nextExecutionTime: job.job.nextInvocation(),
|
||||
running: job.running ? job.running() : false,
|
||||
}))
|
||||
@@ -348,6 +335,7 @@ settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => {
|
||||
id: scheduledJob.id,
|
||||
name: scheduledJob.name,
|
||||
type: scheduledJob.type,
|
||||
interval: scheduledJob.interval,
|
||||
nextExecutionTime: scheduledJob.job.nextInvocation(),
|
||||
running: scheduledJob.running ? scheduledJob.running() : false,
|
||||
});
|
||||
@@ -372,12 +360,45 @@ settingsRoutes.post<{ jobId: string }>(
|
||||
id: scheduledJob.id,
|
||||
name: scheduledJob.name,
|
||||
type: scheduledJob.type,
|
||||
interval: scheduledJob.interval,
|
||||
nextExecutionTime: scheduledJob.job.nextInvocation(),
|
||||
running: scheduledJob.running ? scheduledJob.running() : false,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
settingsRoutes.post<{ jobId: string }>(
|
||||
'/jobs/:jobId/schedule',
|
||||
(req, res, next) => {
|
||||
const scheduledJob = scheduledJobs.find(
|
||||
(job) => job.id === req.params.jobId
|
||||
);
|
||||
|
||||
if (!scheduledJob) {
|
||||
return next({ status: 404, message: 'Job not found' });
|
||||
}
|
||||
|
||||
const result = rescheduleJob(scheduledJob.job, req.body.schedule);
|
||||
const settings = getSettings();
|
||||
|
||||
if (result) {
|
||||
settings.jobs[scheduledJob.id].schedule = req.body.schedule;
|
||||
settings.save();
|
||||
|
||||
return res.status(200).json({
|
||||
id: scheduledJob.id,
|
||||
name: scheduledJob.name,
|
||||
type: scheduledJob.type,
|
||||
interval: scheduledJob.interval,
|
||||
nextExecutionTime: scheduledJob.job.nextInvocation(),
|
||||
running: scheduledJob.running ? scheduledJob.running() : false,
|
||||
});
|
||||
} else {
|
||||
return next({ status: 400, message: 'Invalid job schedule' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
settingsRoutes.get('/cache', (req, res) => {
|
||||
const caches = cacheManager.getAllCaches();
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { User } from '../../entity/User';
|
||||
import { Notification } from '../../lib/notifications';
|
||||
import { NotificationAgent } from '../../lib/notifications/agents/agent';
|
||||
import DiscordAgent from '../../lib/notifications/agents/discord';
|
||||
import EmailAgent from '../../lib/notifications/agents/email';
|
||||
import LunaSeaAgent from '../../lib/notifications/agents/lunasea';
|
||||
@@ -13,6 +15,14 @@ import { getSettings } from '../../lib/settings';
|
||||
|
||||
const notificationRoutes = Router();
|
||||
|
||||
const sendTestNotification = async (agent: NotificationAgent, user: User) =>
|
||||
await agent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyAdmin: false,
|
||||
notifyUser: user,
|
||||
subject: 'Test Notification',
|
||||
message: 'Check check, 1, 2, 3. Are we coming in clear?',
|
||||
});
|
||||
|
||||
notificationRoutes.get('/discord', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
@@ -37,14 +47,7 @@ notificationRoutes.post('/discord/test', async (req, res, next) => {
|
||||
}
|
||||
|
||||
const discordAgent = new DiscordAgent(req.body);
|
||||
if (
|
||||
await discordAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
})
|
||||
) {
|
||||
if (await sendTestNotification(discordAgent, req.user)) {
|
||||
return res.status(204).send();
|
||||
} else {
|
||||
return next({
|
||||
@@ -78,14 +81,7 @@ notificationRoutes.post('/slack/test', async (req, res, next) => {
|
||||
}
|
||||
|
||||
const slackAgent = new SlackAgent(req.body);
|
||||
if (
|
||||
await slackAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
})
|
||||
) {
|
||||
if (await sendTestNotification(slackAgent, req.user)) {
|
||||
return res.status(204).send();
|
||||
} else {
|
||||
return next({
|
||||
@@ -119,14 +115,7 @@ notificationRoutes.post('/telegram/test', async (req, res, next) => {
|
||||
}
|
||||
|
||||
const telegramAgent = new TelegramAgent(req.body);
|
||||
if (
|
||||
await telegramAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
})
|
||||
) {
|
||||
if (await sendTestNotification(telegramAgent, req.user)) {
|
||||
return res.status(204).send();
|
||||
} else {
|
||||
return next({
|
||||
@@ -160,14 +149,7 @@ notificationRoutes.post('/pushbullet/test', async (req, res, next) => {
|
||||
}
|
||||
|
||||
const pushbulletAgent = new PushbulletAgent(req.body);
|
||||
if (
|
||||
await pushbulletAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
})
|
||||
) {
|
||||
if (await sendTestNotification(pushbulletAgent, req.user)) {
|
||||
return res.status(204).send();
|
||||
} else {
|
||||
return next({
|
||||
@@ -201,14 +183,7 @@ notificationRoutes.post('/pushover/test', async (req, res, next) => {
|
||||
}
|
||||
|
||||
const pushoverAgent = new PushoverAgent(req.body);
|
||||
if (
|
||||
await pushoverAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
})
|
||||
) {
|
||||
if (await sendTestNotification(pushoverAgent, req.user)) {
|
||||
return res.status(204).send();
|
||||
} else {
|
||||
return next({
|
||||
@@ -242,14 +217,7 @@ notificationRoutes.post('/email/test', async (req, res, next) => {
|
||||
}
|
||||
|
||||
const emailAgent = new EmailAgent(req.body);
|
||||
if (
|
||||
await emailAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
})
|
||||
) {
|
||||
if (await sendTestNotification(emailAgent, req.user)) {
|
||||
return res.status(204).send();
|
||||
} else {
|
||||
return next({
|
||||
@@ -283,14 +251,7 @@ notificationRoutes.post('/webpush/test', async (req, res, next) => {
|
||||
}
|
||||
|
||||
const webpushAgent = new WebPushAgent(req.body);
|
||||
if (
|
||||
await webpushAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
})
|
||||
) {
|
||||
if (await sendTestNotification(webpushAgent, req.user)) {
|
||||
return res.status(204).send();
|
||||
} else {
|
||||
return next({
|
||||
@@ -369,14 +330,7 @@ notificationRoutes.post('/webhook/test', async (req, res, next) => {
|
||||
};
|
||||
|
||||
const webhookAgent = new WebhookAgent(testBody);
|
||||
if (
|
||||
await webhookAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
})
|
||||
) {
|
||||
if (await sendTestNotification(webhookAgent, req.user)) {
|
||||
return res.status(204).send();
|
||||
} else {
|
||||
return next({
|
||||
@@ -413,14 +367,7 @@ notificationRoutes.post('/lunasea/test', async (req, res, next) => {
|
||||
}
|
||||
|
||||
const lunaseaAgent = new LunaSeaAgent(req.body);
|
||||
if (
|
||||
await lunaseaAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
})
|
||||
) {
|
||||
if (await sendTestNotification(lunaseaAgent, req.user)) {
|
||||
return res.status(204).send();
|
||||
} else {
|
||||
return next({
|
||||
|
||||
@@ -46,6 +46,10 @@ radarrRoutes.post<
|
||||
url: RadarrAPI.buildUrl(req.body, '/api/v3'),
|
||||
});
|
||||
|
||||
const urlBase = await radarr
|
||||
.getSystemStatus()
|
||||
.then((value) => value.urlBase)
|
||||
.catch(() => req.body.baseUrl);
|
||||
const profiles = await radarr.getProfiles();
|
||||
const folders = await radarr.getRootFolders();
|
||||
const tags = await radarr.getTags();
|
||||
@@ -57,6 +61,7 @@ radarrRoutes.post<
|
||||
path: folder.path,
|
||||
})),
|
||||
tags,
|
||||
urlBase,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Failed to test Radarr', {
|
||||
|
||||
@@ -42,6 +42,10 @@ sonarrRoutes.post('/test', async (req, res, next) => {
|
||||
url: SonarrAPI.buildUrl(req.body, '/api/v3'),
|
||||
});
|
||||
|
||||
const urlBase = await sonarr
|
||||
.getSystemStatus()
|
||||
.then((value) => value.urlBase)
|
||||
.catch(() => req.body.baseUrl);
|
||||
const profiles = await sonarr.getProfiles();
|
||||
const folders = await sonarr.getRootFolders();
|
||||
const languageProfiles = await sonarr.getLanguageProfiles();
|
||||
@@ -55,6 +59,7 @@ sonarrRoutes.post('/test', async (req, res, next) => {
|
||||
})),
|
||||
languageProfiles,
|
||||
tags,
|
||||
urlBase,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Failed to test Sonarr', {
|
||||
|
||||
@@ -109,7 +109,7 @@ router.post(
|
||||
|
||||
const user = new User({
|
||||
avatar: body.avatar ?? avatar,
|
||||
username: body.username ?? body.email,
|
||||
username: body.username,
|
||||
email: body.email,
|
||||
password: body.password,
|
||||
permissions: settings.main.defaultPermissions,
|
||||
@@ -194,14 +194,11 @@ router.use('/:id/settings', userSettingsRoutes);
|
||||
router.get<{ id: string }, UserRequestsResponse>(
|
||||
'/:id/requests',
|
||||
async (req, res, next) => {
|
||||
const userRepository = getRepository(User);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
const pageSize = req.query.take ? Number(req.query.take) : 20;
|
||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||
|
||||
try {
|
||||
const user = await userRepository.findOne({
|
||||
const user = await getRepository(User).findOne({
|
||||
where: { id: Number(req.params.id) },
|
||||
});
|
||||
|
||||
@@ -209,12 +206,32 @@ router.get<{ id: string }, UserRequestsResponse>(
|
||||
return next({ status: 404, message: 'User not found.' });
|
||||
}
|
||||
|
||||
const [requests, requestCount] = await requestRepository.findAndCount({
|
||||
where: { requestedBy: user },
|
||||
order: { id: 'DESC' },
|
||||
take: pageSize,
|
||||
skip,
|
||||
});
|
||||
if (
|
||||
user.id !== req.user?.id &&
|
||||
!req.user?.hasPermission(
|
||||
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
|
||||
{ type: 'or' }
|
||||
)
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: "You do not have permission to view this user's requests.",
|
||||
});
|
||||
}
|
||||
|
||||
const [requests, requestCount] = await getRepository(MediaRequest)
|
||||
.createQueryBuilder('request')
|
||||
.leftJoinAndSelect('request.media', 'media')
|
||||
.leftJoinAndSelect('request.seasons', 'seasons')
|
||||
.leftJoinAndSelect('request.modifiedBy', 'modifiedBy')
|
||||
.leftJoinAndSelect('request.requestedBy', 'requestedBy')
|
||||
.andWhere('requestedBy.id = :id', {
|
||||
id: user.id,
|
||||
})
|
||||
.orderBy('request.id', 'DESC')
|
||||
.take(pageSize)
|
||||
.skip(skip)
|
||||
.getManyAndCount();
|
||||
|
||||
return res.status(200).json({
|
||||
pageInfo: {
|
||||
|
||||
@@ -257,6 +257,9 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
|
||||
? settings?.discord.types
|
||||
: 0,
|
||||
discordId: user.settings?.discordId,
|
||||
pushbulletAccessToken: user.settings?.pushbulletAccessToken,
|
||||
pushoverApplicationToken: user.settings?.pushoverApplicationToken,
|
||||
pushoverUserKey: user.settings?.pushoverUserKey,
|
||||
telegramEnabled: settings?.telegram.enabled,
|
||||
telegramBotUsername: settings?.telegram.options.botUsername,
|
||||
telegramChatId: user.settings?.telegramChatId,
|
||||
@@ -298,6 +301,9 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
|
||||
user: req.user,
|
||||
pgpKey: req.body.pgpKey,
|
||||
discordId: req.body.discordId,
|
||||
pushbulletAccessToken: req.body.pushbulletAccessToken,
|
||||
pushoverApplicationToken: req.body.pushoverApplicationToken,
|
||||
pushoverUserKey: req.body.pushoverUserKey,
|
||||
telegramChatId: req.body.telegramChatId,
|
||||
telegramSendSilently: req.body.telegramSendSilently,
|
||||
notificationTypes: req.body.notificationTypes,
|
||||
@@ -305,6 +311,10 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
|
||||
} else {
|
||||
user.settings.pgpKey = req.body.pgpKey;
|
||||
user.settings.discordId = req.body.discordId;
|
||||
user.settings.pushbulletAccessToken = req.body.pushbulletAccessToken;
|
||||
user.settings.pushoverApplicationToken =
|
||||
req.body.pushoverApplicationToken;
|
||||
user.settings.pushoverUserKey = req.body.pushoverUserKey;
|
||||
user.settings.telegramChatId = req.body.telegramChatId;
|
||||
user.settings.telegramSendSilently = req.body.telegramSendSilently;
|
||||
user.settings.notificationTypes = Object.assign(
|
||||
@@ -319,6 +329,9 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
|
||||
return res.status(200).json({
|
||||
pgpKey: user.settings?.pgpKey,
|
||||
discordId: user.settings?.discordId,
|
||||
pushbulletAccessToken: user.settings?.pushbulletAccessToken,
|
||||
pushoverApplicationToken: user.settings?.pushoverApplicationToken,
|
||||
pushoverUserKey: user.settings?.pushoverUserKey,
|
||||
telegramChatId: user.settings?.telegramChatId,
|
||||
telegramSendSilently: user?.settings?.telegramSendSilently,
|
||||
notificationTypes: user.settings.notificationTypes,
|
||||
|
||||
95
server/subscriber/IssueCommentSubscriber.ts
Normal file
95
server/subscriber/IssueCommentSubscriber.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { sortBy } from 'lodash';
|
||||
import {
|
||||
EntitySubscriberInterface,
|
||||
EventSubscriber,
|
||||
getRepository,
|
||||
InsertEvent,
|
||||
} from 'typeorm';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import { IssueType, IssueTypeName } from '../constants/issue';
|
||||
import { MediaType } from '../constants/media';
|
||||
import IssueComment from '../entity/IssueComment';
|
||||
import Media from '../entity/Media';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
import { Permission } from '../lib/permissions';
|
||||
|
||||
@EventSubscriber()
|
||||
export class IssueCommentSubscriber
|
||||
implements EntitySubscriberInterface<IssueComment>
|
||||
{
|
||||
public listenTo(): typeof IssueComment {
|
||||
return IssueComment;
|
||||
}
|
||||
|
||||
private async sendIssueCommentNotification(entity: IssueComment) {
|
||||
let title: string;
|
||||
let image: string;
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
const issue = (
|
||||
await getRepository(IssueComment).findOne({
|
||||
where: { id: entity.id },
|
||||
relations: ['issue'],
|
||||
})
|
||||
)?.issue;
|
||||
if (!issue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const media = await getRepository(Media).findOne({
|
||||
where: { id: issue.media.id },
|
||||
});
|
||||
if (!media) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (media.mediaType === MediaType.MOVIE) {
|
||||
const movie = await tmdb.getMovie({ movieId: media.tmdbId });
|
||||
|
||||
title = `${movie.title}${
|
||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||
}`;
|
||||
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
|
||||
} else {
|
||||
const tvshow = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||
|
||||
title = `${tvshow.name}${
|
||||
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
|
||||
}`;
|
||||
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
|
||||
}
|
||||
|
||||
const [firstComment] = sortBy(issue.comments, 'id');
|
||||
|
||||
if (entity.id !== firstComment.id) {
|
||||
// Send notifications to all issue managers
|
||||
notificationManager.sendNotification(Notification.ISSUE_COMMENT, {
|
||||
event: `New Comment on ${
|
||||
issue.issueType !== IssueType.OTHER
|
||||
? `${IssueTypeName[issue.issueType]} `
|
||||
: ''
|
||||
}Issue`,
|
||||
subject: title,
|
||||
message: firstComment.message,
|
||||
comment: entity,
|
||||
issue,
|
||||
media,
|
||||
image,
|
||||
notifyAdmin: true,
|
||||
notifyUser:
|
||||
!issue.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
|
||||
issue.createdBy.id !== entity.user.id
|
||||
? issue.createdBy
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public afterInsert(event: InsertEvent<IssueComment>): void {
|
||||
if (!event.entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendIssueCommentNotification(event.entity);
|
||||
}
|
||||
}
|
||||
124
server/subscriber/IssueSubscriber.ts
Normal file
124
server/subscriber/IssueSubscriber.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { sortBy } from 'lodash';
|
||||
import {
|
||||
EntitySubscriberInterface,
|
||||
EventSubscriber,
|
||||
InsertEvent,
|
||||
UpdateEvent,
|
||||
} from 'typeorm';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import { IssueStatus, IssueType, IssueTypeName } from '../constants/issue';
|
||||
import { MediaType } from '../constants/media';
|
||||
import Issue from '../entity/Issue';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
import { Permission } from '../lib/permissions';
|
||||
|
||||
@EventSubscriber()
|
||||
export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
|
||||
public listenTo(): typeof Issue {
|
||||
return Issue;
|
||||
}
|
||||
|
||||
private async sendIssueNotification(entity: Issue, type: Notification) {
|
||||
let title: string;
|
||||
let image: string;
|
||||
const tmdb = new TheMovieDb();
|
||||
if (entity.media.mediaType === MediaType.MOVIE) {
|
||||
const movie = await tmdb.getMovie({ movieId: entity.media.tmdbId });
|
||||
|
||||
title = `${movie.title}${
|
||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||
}`;
|
||||
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
|
||||
} else {
|
||||
const tvshow = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
|
||||
|
||||
title = `${tvshow.name}${
|
||||
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
|
||||
}`;
|
||||
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
|
||||
}
|
||||
|
||||
const [firstComment] = sortBy(entity.comments, 'id');
|
||||
const extra: { name: string; value: string }[] = [];
|
||||
|
||||
if (entity.media.mediaType === MediaType.TV && entity.problemSeason > 0) {
|
||||
extra.push({
|
||||
name: 'Affected Season',
|
||||
value: entity.problemSeason.toString(),
|
||||
});
|
||||
|
||||
if (entity.problemEpisode > 0) {
|
||||
extra.push({
|
||||
name: 'Affected Episode',
|
||||
value: entity.problemEpisode.toString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
notificationManager.sendNotification(type, {
|
||||
event:
|
||||
type === Notification.ISSUE_CREATED
|
||||
? `New ${
|
||||
entity.issueType !== IssueType.OTHER
|
||||
? `${IssueTypeName[entity.issueType]} `
|
||||
: ''
|
||||
}Issue Reported`
|
||||
: type === Notification.ISSUE_RESOLVED
|
||||
? `${
|
||||
entity.issueType !== IssueType.OTHER
|
||||
? `${IssueTypeName[entity.issueType]} `
|
||||
: ''
|
||||
}Issue Resolved`
|
||||
: `${
|
||||
entity.issueType !== IssueType.OTHER
|
||||
? `${IssueTypeName[entity.issueType]} `
|
||||
: ''
|
||||
}Issue Reopened`,
|
||||
subject: title,
|
||||
message: firstComment.message,
|
||||
issue: entity,
|
||||
media: entity.media,
|
||||
image,
|
||||
extra,
|
||||
notifyAdmin: true,
|
||||
notifyUser:
|
||||
!entity.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
|
||||
(type === Notification.ISSUE_RESOLVED ||
|
||||
type === Notification.ISSUE_REOPENED)
|
||||
? entity.createdBy
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
public afterInsert(event: InsertEvent<Issue>): void {
|
||||
if (!event.entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendIssueNotification(event.entity, Notification.ISSUE_CREATED);
|
||||
}
|
||||
|
||||
public beforeUpdate(event: UpdateEvent<Issue>): void {
|
||||
if (!event.entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.entity.status === IssueStatus.RESOLVED &&
|
||||
event.databaseEntity.status !== IssueStatus.RESOLVED
|
||||
) {
|
||||
this.sendIssueNotification(
|
||||
event.entity as Issue,
|
||||
Notification.ISSUE_RESOLVED
|
||||
);
|
||||
} else if (
|
||||
event.entity.status === IssueStatus.OPEN &&
|
||||
event.databaseEntity.status !== IssueStatus.OPEN
|
||||
) {
|
||||
this.sendIssueNotification(
|
||||
event.entity as Issue,
|
||||
Notification.ISSUE_REOPENED
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
EntitySubscriberInterface,
|
||||
EventSubscriber,
|
||||
getRepository,
|
||||
Not,
|
||||
UpdateEvent,
|
||||
} from 'typeorm';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
@@ -13,16 +14,24 @@ import Season from '../entity/Season';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
|
||||
@EventSubscriber()
|
||||
export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
private async notifyAvailableMovie(entity: Media, dbEntity?: Media) {
|
||||
export class MediaSubscriber implements EntitySubscriberInterface<Media> {
|
||||
private async notifyAvailableMovie(
|
||||
entity: Media,
|
||||
dbEntity: Media,
|
||||
is4k: boolean
|
||||
) {
|
||||
if (
|
||||
entity.status === MediaStatus.AVAILABLE &&
|
||||
dbEntity?.status !== MediaStatus.AVAILABLE
|
||||
entity[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE &&
|
||||
dbEntity[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE
|
||||
) {
|
||||
if (entity.mediaType === MediaType.MOVIE) {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const relatedRequests = await requestRepository.find({
|
||||
where: { media: entity, is4k: false },
|
||||
where: {
|
||||
media: entity,
|
||||
is4k,
|
||||
status: Not(MediaRequestStatus.DECLINED),
|
||||
},
|
||||
});
|
||||
|
||||
if (relatedRequests.length > 0) {
|
||||
@@ -31,6 +40,8 @@ export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
|
||||
relatedRequests.forEach((request) => {
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
event: `${is4k ? '4K ' : ''}Movie Request Now Available`,
|
||||
notifyAdmin: false,
|
||||
notifyUser: request.requestedBy,
|
||||
subject: `${movie.title}${
|
||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||
@@ -42,7 +53,7 @@ export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
}),
|
||||
media: entity,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
request: request,
|
||||
request,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -50,15 +61,25 @@ export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyAvailableSeries(entity: Media, dbEntity: Media) {
|
||||
private async notifyAvailableSeries(
|
||||
entity: Media,
|
||||
dbEntity: Media,
|
||||
is4k: boolean
|
||||
) {
|
||||
const seasonRepository = getRepository(Season);
|
||||
const newAvailableSeasons = entity.seasons
|
||||
.filter((season) => season.status === MediaStatus.AVAILABLE)
|
||||
.filter(
|
||||
(season) =>
|
||||
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
)
|
||||
.map((season) => season.seasonNumber);
|
||||
const oldSeasonIds = dbEntity.seasons.map((season) => season.id);
|
||||
const oldSeasons = await seasonRepository.findByIds(oldSeasonIds);
|
||||
const oldAvailableSeasons = oldSeasons
|
||||
.filter((season) => season.status === MediaStatus.AVAILABLE)
|
||||
.filter(
|
||||
(season) =>
|
||||
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
)
|
||||
.map((season) => season.seasonNumber);
|
||||
|
||||
const changedSeasons = newAvailableSeasons.filter(
|
||||
@@ -72,7 +93,11 @@ export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
|
||||
for (const changedSeasonNumber of changedSeasons) {
|
||||
const requests = await requestRepository.find({
|
||||
where: { media: entity, is4k: false },
|
||||
where: {
|
||||
media: entity,
|
||||
is4k,
|
||||
status: Not(MediaRequestStatus.DECLINED),
|
||||
},
|
||||
});
|
||||
const request = requests.find(
|
||||
(request) =>
|
||||
@@ -91,6 +116,7 @@ export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
);
|
||||
const tv = await tmdb.getTvShow({ tvId: entity.tmdbId });
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
event: `${is4k ? '4K ' : ''}Series Request Now Available`,
|
||||
subject: `${tv.name}${
|
||||
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
@@ -99,18 +125,19 @@ export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
separator: /\s/,
|
||||
omission: '…',
|
||||
}),
|
||||
notifyAdmin: false,
|
||||
notifyUser: request.requestedBy,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||
media: entity,
|
||||
extra: [
|
||||
{
|
||||
name: 'Seasons',
|
||||
name: 'Requested Seasons',
|
||||
value: request.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
request: request,
|
||||
request,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -144,7 +171,22 @@ export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
event.entity.mediaType === MediaType.MOVIE &&
|
||||
event.entity.status === MediaStatus.AVAILABLE
|
||||
) {
|
||||
this.notifyAvailableMovie(event.entity, event.databaseEntity);
|
||||
this.notifyAvailableMovie(
|
||||
event.entity as Media,
|
||||
event.databaseEntity,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
event.entity.mediaType === MediaType.MOVIE &&
|
||||
event.entity.status4k === MediaStatus.AVAILABLE
|
||||
) {
|
||||
this.notifyAvailableMovie(
|
||||
event.entity as Media,
|
||||
event.databaseEntity,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -152,21 +194,41 @@ export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
(event.entity.status === MediaStatus.AVAILABLE ||
|
||||
event.entity.status === MediaStatus.PARTIALLY_AVAILABLE)
|
||||
) {
|
||||
this.notifyAvailableSeries(event.entity, event.databaseEntity);
|
||||
this.notifyAvailableSeries(
|
||||
event.entity as Media,
|
||||
event.databaseEntity,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
event.entity.mediaType === MediaType.TV &&
|
||||
(event.entity.status4k === MediaStatus.AVAILABLE ||
|
||||
event.entity.status4k === MediaStatus.PARTIALLY_AVAILABLE)
|
||||
) {
|
||||
this.notifyAvailableSeries(
|
||||
event.entity as Media,
|
||||
event.databaseEntity,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
event.entity.status === MediaStatus.AVAILABLE &&
|
||||
event.databaseEntity.status === MediaStatus.PENDING
|
||||
) {
|
||||
this.updateChildRequestStatus(event.entity, false);
|
||||
this.updateChildRequestStatus(event.entity as Media, false);
|
||||
}
|
||||
|
||||
if (
|
||||
event.entity.status4k === MediaStatus.AVAILABLE &&
|
||||
event.databaseEntity.status4k === MediaStatus.PENDING
|
||||
) {
|
||||
this.updateChildRequestStatus(event.entity, true);
|
||||
this.updateChildRequestStatus(event.entity as Media, true);
|
||||
}
|
||||
}
|
||||
|
||||
public listenTo(): typeof Media {
|
||||
return Media;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,25 +6,6 @@ head
|
||||
meta(name='viewport' content='width=device-width, initial-scale=1')
|
||||
meta(name='format-detection' content='telephone=no, date=no, address=no, email=no')
|
||||
link(href='https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' rel='stylesheet' media='screen')
|
||||
//if mso
|
||||
xml
|
||||
o:officedocumentsettings
|
||||
o:pixelsperinch 96
|
||||
style.
|
||||
td,
|
||||
th,
|
||||
div,
|
||||
p,
|
||||
a,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
mso-line-height-rule: exactly;
|
||||
}
|
||||
style.
|
||||
.title:hover * {
|
||||
text-decoration: underline;
|
||||
@@ -35,28 +16,35 @@ head
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
div(style='display: block; background-color: #111827;')
|
||||
table(style='margin: 0 auto; font-family: Inter, Arial, Sans-Serif; color: #fff; font-size: 16px; width: 26rem;')
|
||||
div(style='display: block; background-color: #111827; padding: 2.5rem 0;')
|
||||
table(style='margin: 0 auto; font-family: Inter, Arial, sans-serif; color: #fff; font-size: 16px; width: 26rem;')
|
||||
tr
|
||||
td(style="text-align: center;")
|
||||
a(href=applicationUrl)
|
||||
img(src=applicationUrl +'/logo_full.png' style='width: 26rem; padding: 1rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;')
|
||||
if applicationUrl
|
||||
a(href=applicationUrl style='margin: 0 1rem;')
|
||||
img(src=applicationUrl +'/logo_full.png' style='width: 26rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;')
|
||||
else
|
||||
div(style='margin: 0 1rem 2.5rem; font-size: 3em; font-weight: 700;')
|
||||
| #{applicationTitle}
|
||||
if recipientName !== recipientEmail
|
||||
tr
|
||||
td(style='text-align: center;')
|
||||
div(style='margin: 1rem 0 0; font-size: 1.25em;')
|
||||
| Hi, #{recipientName.replace(/\.|@/g, ((x) => x + '\ufeff'))}!
|
||||
tr
|
||||
td(style='text-align: center;')
|
||||
div(style='margin: 0rem 1rem 1rem; font-size: 1.25em;')
|
||||
span
|
||||
| An account has been created for you at #{applicationTitle}.
|
||||
div(style='margin: 1rem 0 0; font-size: 1.25em;')
|
||||
| An account has been created for you at #{applicationTitle}.
|
||||
tr
|
||||
td(style='text-align: center;')
|
||||
div(style='margin: 1rem 1rem 1rem; font-size: 1.25em;')
|
||||
span
|
||||
| Your new password is:
|
||||
div(style='margin: 0rem 1rem 1rem; font-size: 1.25em;')
|
||||
span
|
||||
div(style='margin: 1rem 0 0; font-size: 1.25em;')
|
||||
| Your password is:
|
||||
div(style='font-size: 1.25em; font-weight: 500; line-height: 2.25em;')
|
||||
span(style='padding: 0.5rem; font-weight: 500; border: 1px solid rgb(100,100,100); font-family: monospace;')
|
||||
| #{password}
|
||||
if applicationUrl
|
||||
tr
|
||||
td
|
||||
a(href=applicationUrl style='display: block; margin: 1.5rem 3rem 2.5rem 3rem; text-decoration: none; font-size: 1.0em; line-height: 2.25em;')
|
||||
span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255, 255, 255, 0.2);')
|
||||
a(href=applicationUrl style='display: block; margin: 1.5rem 3rem 0; text-decoration: none; font-size: 1.0em; line-height: 2.25em;')
|
||||
span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);')
|
||||
| Open #{applicationTitle}
|
||||
|
||||
53
server/templates/email/media-issue/html.pug
Normal file
53
server/templates/email/media-issue/html.pug
Normal file
@@ -0,0 +1,53 @@
|
||||
doctype html
|
||||
head
|
||||
meta(charset='utf-8')
|
||||
meta(name='x-apple-disable-message-reformatting')
|
||||
meta(http-equiv='x-ua-compatible' content='ie=edge')
|
||||
meta(name='viewport' content='width=device-width, initial-scale=1')
|
||||
meta(name='format-detection' content='telephone=no, date=no, address=no, email=no')
|
||||
link(href='https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' rel='stylesheet' media='screen')
|
||||
style.
|
||||
.title:hover * {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@media only screen and (max-width:600px) {
|
||||
table {
|
||||
font-size: 20px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
div(style='display: block; background-color: #111827; padding: 2.5rem 0;')
|
||||
table(style='margin: 0 auto; font-family: Inter, Arial, sans-serif; color: #fff; font-size: 16px; width: 26rem;')
|
||||
tr
|
||||
td(style="text-align: center;")
|
||||
if applicationUrl
|
||||
a(href=applicationUrl style='margin: 0 1rem;')
|
||||
img(src=applicationUrl +'/logo_full.png' style='width: 26rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;')
|
||||
else
|
||||
div(style='margin: 0 1rem 2.5rem; font-size: 3em; font-weight: 700;')
|
||||
| #{applicationTitle}
|
||||
if recipientName !== recipientEmail
|
||||
tr
|
||||
td(style='text-align: center;')
|
||||
div(style='margin: 1rem 0 0; font-size: 1.25em;')
|
||||
| Hi, #{recipientName.replace(/\.|@/g, ((x) => x + '\ufeff'))}!
|
||||
tr
|
||||
td(style='text-align: center;')
|
||||
div(style='margin: 1rem 0 0; font-size: 1.25em;')
|
||||
| #{body}
|
||||
if issueComment
|
||||
tr
|
||||
td(style='text-align: center;')
|
||||
div(style='margin: 1rem 0 0; font-size: 1.25em;')
|
||||
| #{issueComment}
|
||||
else if issueDescription
|
||||
tr
|
||||
td(style='text-align: center;')
|
||||
div(style='margin: 1rem 0 0; font-size: 1.25em;')
|
||||
| #{issueDescription}
|
||||
if actionUrl
|
||||
tr
|
||||
td
|
||||
a(href=actionUrl style='display: block; margin: 1.5rem 3rem 0; text-decoration: none; font-size: 1.0em; line-height: 2.25em;')
|
||||
span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);')
|
||||
| View Issue in #{applicationTitle}
|
||||
1
server/templates/email/media-issue/subject.pug
Normal file
1
server/templates/email/media-issue/subject.pug
Normal file
@@ -0,0 +1 @@
|
||||
!= `${event} - ${mediaName} [${applicationTitle}]`
|
||||
@@ -6,25 +6,6 @@ head
|
||||
meta(name='viewport' content='width=device-width, initial-scale=1')
|
||||
meta(name='format-detection' content='telephone=no, date=no, address=no, email=no')
|
||||
link(href='https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' rel='stylesheet' media='screen')
|
||||
//if mso
|
||||
xml
|
||||
o:officedocumentsettings
|
||||
o:pixelsperinch 96
|
||||
style.
|
||||
td,
|
||||
th,
|
||||
div,
|
||||
p,
|
||||
a,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
mso-line-height-rule: exactly;
|
||||
}
|
||||
style.
|
||||
.title:hover * {
|
||||
text-decoration: underline;
|
||||
@@ -35,31 +16,38 @@ head
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
div(style='display: block; background-color: #111827;')
|
||||
table(style='margin: 0 auto; font-family: Inter, Arial, Sans-Serif; color: #fff; font-size: 16px; width: 26rem;')
|
||||
div(style='display: block; background-color: #111827; padding: 2.5rem 0;')
|
||||
table(style='margin: 0 auto; font-family: Inter, Arial, sans-serif; color: #fff; font-size: 16px; width: 26rem;')
|
||||
tr
|
||||
td(style="text-align: center;")
|
||||
a(href=applicationUrl)
|
||||
img(src=applicationUrl +'/logo_full.png' style='width: 26rem; padding: 1rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;')
|
||||
if applicationUrl
|
||||
a(href=applicationUrl style='margin: 0 1rem;')
|
||||
img(src=applicationUrl +'/logo_full.png' style='width: 26rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;')
|
||||
else
|
||||
div(style='margin: 0 1rem 2.5rem; font-size: 3em; font-weight: 700;')
|
||||
| #{applicationTitle}
|
||||
if recipientName !== recipientEmail
|
||||
tr
|
||||
td(style='text-align: center;')
|
||||
div(style='margin: 1rem 0 0; font-size: 1.25em;')
|
||||
| Hi, #{recipientName.replace(/\.|@/g, ((x) => x + '\ufeff'))}!
|
||||
tr
|
||||
td(style='text-align: center;')
|
||||
div(style='margin: 0rem 1rem 1rem; font-size: 1.25em;')
|
||||
span
|
||||
| #{body}
|
||||
div(style='margin: 1rem 0 0; font-size: 1.25em;')
|
||||
| #{body}
|
||||
tr
|
||||
td
|
||||
div(style='box-sizing: border-box; margin: 0; width: 100%; color: #fff; border-radius: .75rem; padding: 1rem; border: 1px solid rgba(100, 100, 100, 1); background: linear-gradient(135deg, rgba(17, 24, 39, 0.47) 0%, rgb(17, 24, 39) 75%), url(' + imageUrl + ') center 25%/cover')
|
||||
div(style='box-sizing: border-box; margin: 1.5rem 0 0; width: 100%; color: #fff; border-radius: .75rem; padding: 1rem; border: 1px solid rgb(100,100,100); background: linear-gradient(135deg, rgba(17,24,39,0.47) 0%, rgb(17,24,39) 75%), url(' + imageUrl + ') center 25%/cover')
|
||||
table(style='color: #fff; width: 100%;')
|
||||
tr
|
||||
td(style='vertical-align: top;')
|
||||
a(href=actionUrl style='display: block; max-width: 20rem; color: #fff; font-weight: 700; text-decoration: none; margin: 0 1rem 0.25rem 0; font-size: 1.3em; line-height: 1.25em; margin-bottom: 5px;' class='title')
|
||||
span
|
||||
| #{mediaName}
|
||||
| #{mediaName}
|
||||
div(style='overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #d1d5db; font-size: .975em; line-height: 1.45em; padding-top: .25rem; padding-bottom: .25rem;')
|
||||
span(style='display: block;')
|
||||
b(style='color: #9ca3af; font-weight: 700;')
|
||||
| Requested By
|
||||
| #{requestedBy}
|
||||
| #{requestedBy.replace(/\.|@/g, ((x) => x + '\ufeff'))}
|
||||
each extra in mediaExtra
|
||||
span(style='display: block;')
|
||||
b(style='color: #9ca3af; font-weight: 700;')
|
||||
@@ -76,6 +64,6 @@ div(style='display: block; background-color: #111827;')
|
||||
if actionUrl
|
||||
tr
|
||||
td
|
||||
a(href=actionUrl style='display: block; margin: 1.5rem 3rem 2.5rem 3rem; text-decoration: none; font-size: 1.0em; line-height: 2.25em;')
|
||||
span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255, 255, 255, 0.2);')
|
||||
| Open in #{applicationTitle}
|
||||
a(href=actionUrl style='display: block; margin: 1.5rem 3rem 0; text-decoration: none; font-size: 1.0em; line-height: 2.25em;')
|
||||
span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);')
|
||||
| View Media in #{applicationTitle}
|
||||
|
||||
@@ -1 +1 @@
|
||||
!= `${requestType} - ${mediaName} [${applicationTitle}]`
|
||||
!= `${event} - ${mediaName} [${applicationTitle}]`
|
||||
|
||||
@@ -6,25 +6,6 @@ head
|
||||
meta(name='viewport' content='width=device-width, initial-scale=1')
|
||||
meta(name='format-detection' content='telephone=no, date=no, address=no, email=no')
|
||||
link(href='https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' rel='stylesheet' media='screen')
|
||||
//if mso
|
||||
xml
|
||||
o:officedocumentsettings
|
||||
o:pixelsperinch 96
|
||||
style.
|
||||
td,
|
||||
th,
|
||||
div,
|
||||
p,
|
||||
a,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
mso-line-height-rule: exactly;
|
||||
}
|
||||
style.
|
||||
.title:hover * {
|
||||
text-decoration: underline;
|
||||
@@ -35,25 +16,35 @@ head
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
div(style='display: block; background-color: #111827;')
|
||||
table(style='margin: 0 auto; font-family: Inter, Arial, Sans-Serif; color: #fff; font-size: 16px; width: 26rem;')
|
||||
div(style='display: block; background-color: #111827; padding: 2.5rem 0;')
|
||||
table(style='margin: 0 auto; font-family: Inter, Arial, sans-serif; color: #fff; font-size: 16px; width: 26rem;')
|
||||
tr
|
||||
td(style="text-align: center;")
|
||||
a(href=applicationUrl)
|
||||
img(src=applicationUrl +'/logo_full.png' style='width: 26rem; padding: 1rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;')
|
||||
tr
|
||||
td(style='text-align: center;')
|
||||
div(style='margin: 0rem 1rem 1rem; font-size: 1.25em;')
|
||||
span
|
||||
| Your #{applicationTitle} account password was requested to be reset. Click below to reset your password.
|
||||
if resetPasswordLink
|
||||
if applicationUrl
|
||||
a(href=applicationUrl style='margin: 0 1rem;')
|
||||
img(src=applicationUrl +'/logo_full.png' style='width: 26rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;')
|
||||
else
|
||||
div(style='margin: 0 1rem 2.5rem; font-size: 3em; font-weight: 700;')
|
||||
| #{applicationTitle}
|
||||
if recipientName !== recipientEmail
|
||||
tr
|
||||
td
|
||||
a(href=resetPasswordLink style='display: block; margin: 1.5rem 3rem 2.5rem 3rem; text-decoration: none; font-size: 1.0em; line-height: 2.25em;')
|
||||
span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255, 255, 255, 0.2);')
|
||||
| Reset Password
|
||||
td(style='text-align: center;')
|
||||
div(style='margin: 1rem 0 0; font-size: 1.25em;')
|
||||
| Hi, #{recipientName.replace(/\.|@/g, ((x) => x + '\ufeff'))}!
|
||||
tr
|
||||
td(style='text-align: center;')
|
||||
div(style='margin: 1rem; font-size: .85em;')
|
||||
span
|
||||
| If you did not request that your password be reset, you can safely ignore this email.
|
||||
div(style='margin: 1rem 0 0; font-size: 1.25em;')
|
||||
| A request has been received to change the password for your #{applicationTitle} account.
|
||||
tr
|
||||
td
|
||||
a(href=resetPasswordLink style='display: block; margin: 1.5rem 3rem; text-decoration: none; font-size: 1.0em; line-height: 2.25em;')
|
||||
span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);')
|
||||
| Reset Password
|
||||
tr
|
||||
td(style='text-align: center;')
|
||||
div(style='margin: 1rem 0 0; font-size: 1.25em;')
|
||||
| The above link will expire in 24 hours.
|
||||
tr
|
||||
td(style='text-align: center;')
|
||||
div(style='margin: 1rem 1rem 0; font-size: 1.25em;')
|
||||
| If you did not initiate this request, you may safely disregard this message.
|
||||
@@ -6,25 +6,6 @@ head
|
||||
meta(name='viewport' content='width=device-width, initial-scale=1')
|
||||
meta(name='format-detection' content='telephone=no, date=no, address=no, email=no')
|
||||
link(href='https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' rel='stylesheet' media='screen')
|
||||
//if mso
|
||||
xml
|
||||
o:officedocumentsettings
|
||||
o:pixelsperinch 96
|
||||
style.
|
||||
td,
|
||||
th,
|
||||
div,
|
||||
p,
|
||||
a,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
mso-line-height-rule: exactly;
|
||||
}
|
||||
style.
|
||||
.title:hover * {
|
||||
text-decoration: underline;
|
||||
@@ -35,20 +16,28 @@ head
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
div(style='display: block; background-color: #111827;')
|
||||
table(style='margin: 0 auto; font-family: Inter, Arial, Sans-Serif; color: #fff; font-size: 16px; width: 26rem;')
|
||||
div(style='display: block; background-color: #111827; padding: 2.5rem 0;')
|
||||
table(style='margin: 0 auto; font-family: Inter, Arial, sans-serif; color: #fff; font-size: 16px; width: 26rem;')
|
||||
tr
|
||||
td(style="text-align: center;")
|
||||
a(href=applicationUrl)
|
||||
img(src=applicationUrl +'/logo_full.png' style='width: 26rem; padding: 1rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;')
|
||||
if applicationUrl
|
||||
a(href=applicationUrl style='margin: 0 1rem;')
|
||||
img(src=applicationUrl +'/logo_full.png' style='width: 26rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;')
|
||||
else
|
||||
div(style='margin: 0 1rem 2.5rem; font-size: 3em; font-weight: 700;')
|
||||
| #{applicationTitle}
|
||||
if recipientName !== recipientEmail
|
||||
tr
|
||||
td(style='text-align: center;')
|
||||
div(style='margin: 1rem 0 0; font-size: 1.25em;')
|
||||
| Hi, #{recipientName.replace(/\.|@/g, ((x) => x + '\ufeff'))}!
|
||||
tr
|
||||
td(style='text-align: center;')
|
||||
div(style='margin: 0rem 1rem 1rem; font-size: 1.25em;')
|
||||
span
|
||||
| #{body}
|
||||
div(style='margin: 1rem 0 0; font-size: 1.25em;')
|
||||
| #{body}
|
||||
if applicationUrl
|
||||
tr
|
||||
td
|
||||
a(href=applicationUrl style='display: block; margin: 1.5rem 3rem 2.5rem 3rem; text-decoration: none; font-size: 1.0em; line-height: 2.25em;')
|
||||
span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255, 255, 255, 0.2);')
|
||||
a(href=applicationUrl style='display: block; margin: 1.5rem 3rem 0; text-decoration: none; font-size: 1.0em; line-height: 2.25em;')
|
||||
span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);')
|
||||
| Open #{applicationTitle}
|
||||
|
||||
9
server/types/plex-api.d.ts
vendored
9
server/types/plex-api.d.ts
vendored
@@ -21,6 +21,13 @@ declare module 'plex-api' {
|
||||
requestOptions?: Record<string, string | number>;
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
query: <T extends Record<string, any>>(endpoint: string) => Promise<T>;
|
||||
query: <T extends Record<string, any>>(
|
||||
endpoint:
|
||||
| string
|
||||
| {
|
||||
uri: string;
|
||||
extraHeaders?: Record<string, string | number>;
|
||||
}
|
||||
) => Promise<T>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ confinement: strict
|
||||
parts:
|
||||
overseerr:
|
||||
plugin: nodejs
|
||||
nodejs-version: '14.17.0'
|
||||
nodejs-version: '14.18.1'
|
||||
nodejs-package-manager: 'yarn'
|
||||
nodejs-yarn-version: v1.22.10
|
||||
build-packages:
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { DownloadIcon, DuplicateIcon } from '@heroicons/react/outline';
|
||||
import axios from 'axios';
|
||||
import { DownloadIcon } from '@heroicons/react/outline';
|
||||
import { uniq } from 'lodash';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import type { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
import type { Collection } from '../../../server/models/Collection';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { Permission, useUser } from '../../hooks/useUser';
|
||||
@@ -17,23 +14,17 @@ import Error from '../../pages/_error';
|
||||
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
|
||||
import CachedImage from '../Common/CachedImage';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import Modal from '../Common/Modal';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import RequestModal from '../RequestModal';
|
||||
import Slider from '../Slider';
|
||||
import StatusBadge from '../StatusBadge';
|
||||
import TitleCard from '../TitleCard';
|
||||
import Transition from '../Transition';
|
||||
|
||||
const messages = defineMessages({
|
||||
overview: 'Overview',
|
||||
numberofmovies: '{count} Movies',
|
||||
requestcollection: 'Request Collection',
|
||||
requestswillbecreated:
|
||||
'The following titles will have requests created for them:',
|
||||
requestcollection4k: 'Request Collection in 4K',
|
||||
requestswillbecreated4k:
|
||||
'The following titles will have 4K requests created for them:',
|
||||
requestSuccess: '<strong>{title}</strong> requested successfully!',
|
||||
});
|
||||
|
||||
interface CollectionDetailsProps {
|
||||
@@ -46,10 +37,8 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
||||
const intl = useIntl();
|
||||
const router = useRouter();
|
||||
const settings = useSettings();
|
||||
const { addToast } = useToasts();
|
||||
const { hasPermission } = useUser();
|
||||
const [requestModal, setRequestModal] = useState(false);
|
||||
const [isRequesting, setRequesting] = useState(false);
|
||||
const [is4k, setIs4k] = useState(false);
|
||||
|
||||
const { data, error, revalidate } = useSWR<Collection>(
|
||||
@@ -124,48 +113,6 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
||||
!part.mediaInfo || part.mediaInfo.status4k === MediaStatus.UNKNOWN
|
||||
).length > 0;
|
||||
|
||||
const requestableParts = data.parts.filter(
|
||||
(part) =>
|
||||
!part.mediaInfo ||
|
||||
part.mediaInfo[is4k ? 'status4k' : 'status'] === MediaStatus.UNKNOWN
|
||||
);
|
||||
|
||||
const requestBundle = async () => {
|
||||
try {
|
||||
setRequesting(true);
|
||||
await Promise.all(
|
||||
requestableParts.map(async (part) => {
|
||||
await axios.post<MediaRequest>('/api/v1/request', {
|
||||
mediaId: part.id,
|
||||
mediaType: 'movie',
|
||||
is4k,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
addToast(
|
||||
<span>
|
||||
{intl.formatMessage(messages.requestSuccess, {
|
||||
title: data?.name,
|
||||
strong: function strong(msg) {
|
||||
return <strong>{msg}</strong>;
|
||||
},
|
||||
})}
|
||||
</span>,
|
||||
{ appearance: 'success', autoDismiss: true }
|
||||
);
|
||||
} catch (e) {
|
||||
addToast('Something went wrong requesting the collection.', {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
setRequestModal(false);
|
||||
revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
const collectionAttributes: React.ReactNode[] = [];
|
||||
|
||||
collectionAttributes.push(
|
||||
@@ -229,53 +176,17 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
||||
</div>
|
||||
)}
|
||||
<PageTitle title={data.name} />
|
||||
<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"
|
||||
<RequestModal
|
||||
tmdbId={data.id}
|
||||
show={requestModal}
|
||||
>
|
||||
<Modal
|
||||
onOk={() => requestBundle()}
|
||||
okText={
|
||||
isRequesting
|
||||
? intl.formatMessage(globalMessages.requesting)
|
||||
: intl.formatMessage(
|
||||
is4k ? globalMessages.request4k : globalMessages.request
|
||||
)
|
||||
}
|
||||
okDisabled={isRequesting}
|
||||
okButtonType="primary"
|
||||
onCancel={() => setRequestModal(false)}
|
||||
title={intl.formatMessage(
|
||||
is4k ? messages.requestcollection4k : messages.requestcollection
|
||||
)}
|
||||
iconSvg={<DuplicateIcon />}
|
||||
>
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
is4k
|
||||
? messages.requestswillbecreated4k
|
||||
: messages.requestswillbecreated
|
||||
)}
|
||||
</p>
|
||||
<ul className="py-4 pl-8 list-disc">
|
||||
{data.parts
|
||||
.filter(
|
||||
(part) =>
|
||||
!part.mediaInfo ||
|
||||
part.mediaInfo[is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.UNKNOWN
|
||||
)
|
||||
.map((part) => (
|
||||
<li key={`request-part-${part.id}`}>{part.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Modal>
|
||||
</Transition>
|
||||
type="collection"
|
||||
is4k={is4k}
|
||||
onComplete={() => {
|
||||
revalidate();
|
||||
setRequestModal(false);
|
||||
}}
|
||||
onCancel={() => setRequestModal(false)}
|
||||
/>
|
||||
<div className="media-header">
|
||||
<div className="media-poster">
|
||||
<CachedImage
|
||||
@@ -323,7 +234,9 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
||||
.map((t, k) => <span key={k}>{t}</span>)
|
||||
.reduce((prev, curr) => (
|
||||
<>
|
||||
{prev} | {curr}
|
||||
{prev}
|
||||
<span>|</span>
|
||||
{curr}
|
||||
</>
|
||||
))}
|
||||
</span>
|
||||
|
||||
@@ -3,36 +3,69 @@ import React from 'react';
|
||||
interface BadgeProps {
|
||||
badgeType?: 'default' | 'primary' | 'danger' | 'warning' | 'success';
|
||||
className?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
const Badge: React.FC<BadgeProps> = ({
|
||||
badgeType = 'default',
|
||||
className,
|
||||
url,
|
||||
children,
|
||||
}) => {
|
||||
const badgeStyle = [
|
||||
'px-2 inline-flex text-xs leading-5 font-semibold rounded-full cursor-default',
|
||||
'px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap',
|
||||
];
|
||||
|
||||
if (url) {
|
||||
badgeStyle.push('transition cursor-pointer !no-underline');
|
||||
} else {
|
||||
badgeStyle.push('cursor-default');
|
||||
}
|
||||
|
||||
switch (badgeType) {
|
||||
case 'danger':
|
||||
badgeStyle.push('bg-red-600 text-red-100');
|
||||
badgeStyle.push('bg-red-600 !text-red-100');
|
||||
if (url) {
|
||||
badgeStyle.push('hover:bg-red-500');
|
||||
}
|
||||
break;
|
||||
case 'warning':
|
||||
badgeStyle.push('bg-yellow-500 text-yellow-100');
|
||||
badgeStyle.push('bg-yellow-500 !text-yellow-100');
|
||||
if (url) {
|
||||
badgeStyle.push('hover:bg-yellow-400');
|
||||
}
|
||||
break;
|
||||
case 'success':
|
||||
badgeStyle.push('bg-green-500 text-green-100');
|
||||
badgeStyle.push('bg-green-500 !text-green-100');
|
||||
if (url) {
|
||||
badgeStyle.push('hover:bg-green-400');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
badgeStyle.push('bg-indigo-500 text-indigo-100');
|
||||
badgeStyle.push('bg-indigo-500 !text-indigo-100');
|
||||
if (url) {
|
||||
badgeStyle.push('hover:bg-indigo-400');
|
||||
}
|
||||
}
|
||||
|
||||
if (className) {
|
||||
badgeStyle.push(className);
|
||||
}
|
||||
|
||||
return <span className={badgeStyle.join(' ')}>{children}</span>;
|
||||
if (url) {
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={badgeStyle.join(' ')}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
return <span className={badgeStyle.join(' ')}>{children}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
export default Badge;
|
||||
|
||||
@@ -111,7 +111,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||
}}
|
||||
>
|
||||
{backdrop && (
|
||||
<div className="absolute top-0 left-0 right-0 z-0 w-full h-64">
|
||||
<div className="absolute top-0 left-0 right-0 z-0 w-full h-64 max-h-full">
|
||||
<CachedImage
|
||||
alt=""
|
||||
src={backdrop}
|
||||
@@ -128,16 +128,16 @@ const Modal: React.FC<ModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative sm:flex sm:items-center">
|
||||
<div className="relative overflow-x-hidden sm:flex sm:items-center">
|
||||
{iconSvg && <div className="modal-icon">{iconSvg}</div>}
|
||||
<div
|
||||
className={`mt-3 text-center sm:mt-0 sm:text-left ${
|
||||
className={`mt-3 text-center sm:mt-0 sm:text-left truncate text-white ${
|
||||
iconSvg ? 'sm:ml-4' : 'sm:mb-4'
|
||||
}`}
|
||||
>
|
||||
{title && (
|
||||
<span
|
||||
className="text-lg font-bold leading-6 text-white"
|
||||
className="text-lg font-bold leading-6 truncate"
|
||||
id="modal-headline"
|
||||
>
|
||||
{title}
|
||||
|
||||
@@ -7,7 +7,7 @@ import Transition from '../../Transition';
|
||||
|
||||
interface SlideOverProps {
|
||||
show?: boolean;
|
||||
title: string;
|
||||
title: React.ReactNode;
|
||||
subText?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -38,12 +38,24 @@ const networks: Network[] = [
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/4KAy34EHvRM25Ih8wb82AuGU7zJ.png',
|
||||
url: '/discover/tv/network/2552',
|
||||
},
|
||||
{
|
||||
name: 'Hulu',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/pqUTCleNUiTLAVlelGxUgWn1ELh.png',
|
||||
url: '/discover/tv/network/453',
|
||||
},
|
||||
{
|
||||
name: 'HBO',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/tuomPhY2UtuPTqqFnKMVHvSb724.png',
|
||||
url: '/discover/tv/network/49',
|
||||
},
|
||||
{
|
||||
name: 'Discovery+',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/1D1bS3Dyw4ScYnFWTlBOvJXC3nb.png',
|
||||
url: '/discover/tv/network/4353',
|
||||
},
|
||||
{
|
||||
name: 'ABC',
|
||||
image:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user