mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2026-01-01 04:08:45 -05:00
Merge branch 'develop'
This commit is contained in:
@@ -539,23 +539,138 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"code"
|
"code"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
{
|
{
|
||||||
"login": "Fallenbagel",
|
"login": "sootylunatic",
|
||||||
"name": "Mohamed Nuvaas",
|
"name": "sootylunatic",
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/98979876?s=96&v=4",
|
"avatar_url": "https://avatars.githubusercontent.com/u/36486087?v=4",
|
||||||
"profile": "https://github.com/nicospz",
|
"profile": "https://github.com/sootylunatic",
|
||||||
"contributions": [
|
"contributions": [
|
||||||
"code",
|
"translation"
|
||||||
"logo",
|
]
|
||||||
"design"
|
},
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "schambers",
|
||||||
|
"name": "Sean Chambers",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/31563?v=4",
|
||||||
|
"profile": "https://github.com/schambers",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "deniscerri",
|
||||||
|
"name": "deniscerri",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/64997243?v=4",
|
||||||
|
"profile": "https://github.com/deniscerri",
|
||||||
|
"contributions": [
|
||||||
|
"translation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "tomgacz",
|
||||||
|
"name": "tomgacz",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/14138209?v=4",
|
||||||
|
"profile": "https://github.com/tomgacz",
|
||||||
|
"contributions": [
|
||||||
|
"translation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Andersborrits",
|
||||||
|
"name": "Andersborrits",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/29452218?v=4",
|
||||||
|
"profile": "https://github.com/Andersborrits",
|
||||||
|
"contributions": [
|
||||||
|
"translation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Maxentr",
|
||||||
|
"name": "Maxent",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/67283154?v=4",
|
||||||
|
"profile": "http://maxentrouault.fr",
|
||||||
|
"contributions": [
|
||||||
|
"translation"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
||||||
"contributorsPerLine": 7,
|
"contributorsPerLine": 7,
|
||||||
"projectName": "jellyseerr",
|
"projectName": "overseerr",
|
||||||
"projectOwner": "Fallenbagel",
|
"projectOwner": "sct",
|
||||||
"repoType": "github",
|
"repoType": "github",
|
||||||
"repoHost": "https://github.com",
|
"repoHost": "https://github.com",
|
||||||
"skipCi": true
|
"skipCi": true
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
.gitconfig
|
.gitconfig
|
||||||
.github
|
.github
|
||||||
.gitignore
|
.gitignore
|
||||||
|
.husky
|
||||||
.next
|
.next
|
||||||
.prettierignore
|
.prettierignore
|
||||||
config/db/*
|
config/db/*
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ module.exports = {
|
|||||||
'plugin:jsx-a11y/recommended',
|
'plugin:jsx-a11y/recommended',
|
||||||
'plugin:react/recommended',
|
'plugin:react/recommended',
|
||||||
'plugin:react-hooks/recommended',
|
'plugin:react-hooks/recommended',
|
||||||
|
'prettier',
|
||||||
],
|
],
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
ecmaVersion: 6,
|
ecmaVersion: 6,
|
||||||
@@ -25,6 +26,7 @@ module.exports = {
|
|||||||
'react-hooks/rules-of-hooks': 'error',
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
'react-hooks/exhaustive-deps': 'warn',
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
|
'prettier/prettier': ['error', { endOfLine: 'auto' }],
|
||||||
'formatjs/no-offset': 'error',
|
'formatjs/no-offset': 'error',
|
||||||
'no-unused-vars': 'off',
|
'no-unused-vars': 'off',
|
||||||
'@typescript-eslint/no-unused-vars': ['error'],
|
'@typescript-eslint/no-unused-vars': ['error'],
|
||||||
@@ -38,7 +40,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
plugins: ['jsx-a11y', 'react-hooks', 'formatjs'],
|
plugins: ['jsx-a11y', 'prettier', 'react-hooks', 'formatjs'],
|
||||||
settings: {
|
settings: {
|
||||||
react: {
|
react: {
|
||||||
pragma: 'React',
|
pragma: 'React',
|
||||||
|
|||||||
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/fallenbagel/jellyseerr/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.
|
|
||||||
10
.github/ISSUE_TEMPLATE/config.yml
vendored
10
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,8 +1,8 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Support via Discord
|
- name: 💬 Support via Discord
|
||||||
url: https://discord.gg/overseerr
|
url: https://discord.gg/ckbvBtDJgC
|
||||||
about: Chat with users and devs on support and setup related topics.
|
about: Chat with other users and the Overseerr dev team
|
||||||
- name: Support via GitHub Discussions
|
- name: 💬 Support via GitHub Discussions
|
||||||
url: https://github.com/sct/overseerr/discussions
|
url: https://github.com/fallenbagel/jellyseerr/discussions
|
||||||
about: Ask questions and discuss with other community members
|
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/fallenbagel/jellyseerr/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.
|
|
||||||
6
.github/PULL_REQUEST_TEMPLATE.md
vendored
6
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -4,4 +4,10 @@
|
|||||||
|
|
||||||
#### To-Dos
|
#### To-Dos
|
||||||
|
|
||||||
|
- [ ] Successful build `yarn build`
|
||||||
|
- [ ] Translation keys `yarn i18n:extract`
|
||||||
|
- [ ] Database migration (if required)
|
||||||
|
|
||||||
#### Issues Fixed or Closed
|
#### Issues Fixed or Closed
|
||||||
|
|
||||||
|
- Fixes #XXXX
|
||||||
|
|||||||
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
|
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
|
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:
|
exemptLabels:
|
||||||
- pinned
|
- pinned
|
||||||
- security
|
- security
|
||||||
- dependencies
|
- 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
|
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: >
|
markComment: >
|
||||||
This issue has been automatically marked as stale because it has not had
|
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
|
recent activity. It will be closed if no further activity occurs. Thank you
|
||||||
for your contributions.
|
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.
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -39,6 +39,7 @@ config/settings.json
|
|||||||
config/logs/*.log*
|
config/logs/*.log*
|
||||||
config/logs/*.json
|
config/logs/*.json
|
||||||
config/logs/*.log.gz
|
config/logs/*.log.gz
|
||||||
|
config/logs/*.json.gz
|
||||||
config/logs/*-audit.json
|
config/logs/*-audit.json
|
||||||
|
|
||||||
# anidb mapping file
|
# anidb mapping file
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
. "$(dirname "$0")/_/husky.sh"
|
. "$(dirname "$0")/_/husky.sh"
|
||||||
|
|
||||||
[[ -n $HUSKY_BYPASS ]] || commitlint -E HUSKY_GIT_PARAMS
|
[[ -n $HUSKY_BYPASS ]] || npx commitlint --edit $1
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
. "$(dirname "$0")/_/husky.sh"
|
. "$(dirname "$0")/_/husky.sh"
|
||||||
|
|
||||||
npm test
|
npx lint-staged
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
. "$(dirname "$0")/_/husky.sh"
|
. "$(dirname "$0")/_/husky.sh"
|
||||||
|
|
||||||
exec < /dev/tty && git cz --hook || true
|
exec < /dev/tty && npx cz --hook || true
|
||||||
|
|||||||
5
.vscode/extensions.json
vendored
5
.vscode/extensions.json
vendored
@@ -19,9 +19,6 @@
|
|||||||
|
|
||||||
"stylelint.vscode-stylelint",
|
"stylelint.vscode-stylelint",
|
||||||
|
|
||||||
"bradlc.vscode-tailwindcss",
|
"bradlc.vscode-tailwindcss"
|
||||||
|
|
||||||
// https://marketplace.visualstudio.com/items?itemName=heybourn.headwind
|
|
||||||
"heybourn.headwind"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@@ -15,7 +15,6 @@
|
|||||||
"database": "./config/db/db.sqlite3"
|
"database": "./config/db/db.sqlite3"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"i18n-ally.localesPaths": ["src/i18n", "src/i18n/locale"],
|
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.organizeImports": true
|
"source.organizeImports": true
|
||||||
},
|
},
|
||||||
|
|||||||
1198
CHANGELOG.md
1198
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
|
|||||||
2. Add the remote `upstream`:
|
2. Add the remote `upstream`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git remote add upstream https://github.com/sct/overseerr.git
|
git remote add upstream https://github.com/fallenbagel/jellyseerr.git
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Create a new branch:
|
3. Create a new branch:
|
||||||
@@ -66,17 +66,17 @@ All help is welcome and greatly appreciated! If you would like to contribute to
|
|||||||
|
|
||||||
### Contributing Code
|
### Contributing Code
|
||||||
|
|
||||||
- If you are taking on an existing bug or feature ticket, please comment on the [issue](https://github.com/sct/overseerr/issues) to avoid multiple people working on the same thing.
|
- If you are taking on an existing bug or feature ticket, please comment on the [issue](https://github.com/fallenbagel/jellyseerr/issues) to avoid multiple people working on the same thing.
|
||||||
- All commits **must** follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
|
- All commits **must** follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||||
- It is okay to squash your pull request down into a single commit that fits this standard.
|
|
||||||
- Pull requests with commits not following this standard will **not** be merged.
|
- Pull requests with commits not following this standard will **not** be merged.
|
||||||
- Please make meaningful commits, or squash them.
|
- Please make meaningful commits, or squash them prior to opening a pull request.
|
||||||
|
- Do not squash commits once people have begun reviewing your changes.
|
||||||
- Always rebase your commit to the latest `develop` branch. Do **not** merge `develop` into your branch.
|
- Always rebase your commit to the latest `develop` branch. Do **not** merge `develop` into your branch.
|
||||||
- It is your responsibility to keep your branch up-to-date. Your work will **not** be merged unless it is rebased off the latest `develop` branch.
|
- It is your responsibility to keep your branch up-to-date. Your work will **not** be merged unless it is rebased off the latest `develop` branch.
|
||||||
- You can create a "draft" pull request early to get feedback on your work.
|
- You can create a "draft" pull request early to get feedback on your work.
|
||||||
- Your code **must** be formatted correctly, or the tests will fail.
|
- Your code **must** be formatted correctly, or the tests will fail.
|
||||||
- We use Prettier to format our code base. It should automatically run with a Git hook, but it is recommended to have the Prettier extension installed in your editor and format on save.
|
- We use Prettier to format our code base. It should automatically run with a Git hook, but it is recommended to have the Prettier extension installed in your editor and format on save.
|
||||||
- If you have questions or need help, you can reach out via [Discussions](https://github.com/sct/overseerr/discussions) or our [Discord server](https://discord.gg/overseerr).
|
- If you have questions or need help, you can reach out via [Discussions](https://github.com/fallenbagel/jellyseerr/discussions) or our [Discord server](https://discord.gg/ckbvBtDJgC).
|
||||||
- Only open pull requests to `develop`, never `master`! Any pull requests opened to `master` will be closed.
|
- Only open pull requests to `develop`, never `master`! Any pull requests opened to `master` will be closed.
|
||||||
|
|
||||||
### UI Text Style
|
### UI Text Style
|
||||||
@@ -97,7 +97,7 @@ When adding new UI text, please try to adhere to the following guidelines:
|
|||||||
|
|
||||||
## Translation
|
## Translation
|
||||||
|
|
||||||
We use [Weblate](https://hosted.weblate.org/engage/overseerr/) for our translations, and your help with localizing Overseerr would be greatly appreciated! If your language is not listed below, please [open a feature request](https://github.com/sct/overseerr/issues/new/choose).
|
We use [Weblate](https://hosted.weblate.org/engage/overseerr/) for our translations, and your help with localizing Overseerr would be greatly appreciated! If your language is not listed below, please [open a feature request](https://github.com/fallenbagel/jellyseerr/issues/new/choose).
|
||||||
|
|
||||||
<a href="https://hosted.weblate.org/engage/overseerr/"><img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/multi-auto.svg" alt="Translation status" /></a>
|
<a href="https://hosted.weblate.org/engage/overseerr/"><img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/multi-auto.svg" alt="Translation status" /></a>
|
||||||
|
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@@ -1,4 +1,4 @@
|
|||||||
FROM node:14.17-alpine AS BUILD_IMAGE
|
FROM node:16.14-alpine AS BUILD_IMAGE
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -7,8 +7,10 @@ ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
|
|||||||
|
|
||||||
RUN \
|
RUN \
|
||||||
case "${TARGETPLATFORM}" in \
|
case "${TARGETPLATFORM}" in \
|
||||||
'linux/arm64') apk add --no-cache python make g++ ;; \
|
'linux/arm64' | 'linux/arm/v7') \
|
||||||
'linux/arm/v7') apk add --no-cache python make g++ ;; \
|
apk add --no-cache python3 make g++ && \
|
||||||
|
ln -s /usr/bin/python3 /usr/bin/python \
|
||||||
|
;; \
|
||||||
esac
|
esac
|
||||||
|
|
||||||
COPY package.json yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
@@ -24,18 +26,18 @@ RUN yarn build
|
|||||||
# remove development dependencies
|
# remove development dependencies
|
||||||
RUN yarn install --production --ignore-scripts --prefer-offline
|
RUN yarn install --production --ignore-scripts --prefer-offline
|
||||||
|
|
||||||
RUN rm -rf src server
|
RUN rm -rf src server .next/cache
|
||||||
|
|
||||||
RUN touch config/DOCKER
|
RUN touch config/DOCKER
|
||||||
|
|
||||||
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
||||||
|
|
||||||
|
|
||||||
FROM node:14.17-alpine
|
FROM node:16.14-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache tzdata tini
|
RUN apk add --no-cache tzdata tini && rm -rf /tmp/*
|
||||||
|
|
||||||
# copy from build image
|
# copy from build image
|
||||||
COPY --from=BUILD_IMAGE /app ./
|
COPY --from=BUILD_IMAGE /app ./
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:14.17-alpine
|
FROM node:16.14-alpine
|
||||||
|
|
||||||
COPY . /app
|
COPY . /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
52
README.md
52
README.md
@@ -3,49 +3,57 @@
|
|||||||
</p>
|
</p>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/badge/Discord-Chat-lightgrey" alt="Discord"></a>
|
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/badge/Discord-Chat-lightgrey" alt="Discord"></a>
|
||||||
|
<a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a>
|
||||||
|
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Jellyseerr** is a free and open source fork of Overseerr for managing requests for your media library. It integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**, and **[Jellyfin](https://jellyfin.org/)**!
|
**Jellyseerr** is a free and open source software application for managing requests for your media library. It is a a fork of Overseerr built to bring support for Jellyfin & Emby media servers!
|
||||||
|
|
||||||
## Current Features
|
## Current Features
|
||||||
|
|
||||||
- Jellyfin support
|
- Jellyfin Support
|
||||||
- Easy integration with your existing services. Currently, Jellyseerr supports Sonarr and Radarr.
|
- Emby Support
|
||||||
- Jellyfin library scan, to keep track of the titles which are already available.
|
|
||||||
|
Along with all the existing Overseerr features:
|
||||||
|
|
||||||
|
- Full Plex integration. Authenticate and manage user access with Plex!
|
||||||
|
- Easy integration with your existing services. Currently, Jellyseerr supports Sonarr and Radarr. More to come!
|
||||||
|
- Plex library scan, to keep track of the titles which are already available.
|
||||||
- Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface.
|
- Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface.
|
||||||
- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests!
|
- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests!
|
||||||
- Granular permission system.
|
- Granular permission system.
|
||||||
- Support for various notification agents.
|
- Support for various notification agents.
|
||||||
- Mobile-friendly design, for when you need to approve requests on the go!
|
- Mobile-friendly design, for when you need to approve requests on the go!
|
||||||
|
|
||||||
Check out our [issue tracker](https://github.com/Fallenbagel/jellyseerr/issues).
|
With more features on the way! Check out our [issue tracker](https://github.com/fallenbagel/jellyseerr/issues) to see the features which have already been requested.
|
||||||
|
|
||||||
## Supported Architectures
|
|
||||||
|
|
||||||
Jellyseerr image support multiple architectures such as x86-64, arm64 and armv7.
|
|
||||||
**NOTE: `:arm` and `:armv7` tag has been deprecated and replaced with `:latest`.**
|
|
||||||
|
|
||||||
| **Architecture** | **Tag** |
|
|
||||||
| ---------------- | ------- |
|
|
||||||
| x86-64 | latest |
|
|
||||||
| ARM64 | latest |
|
|
||||||
| ARMv7 | latest |
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
Check out our dockerhub for instructions on how to install and run Jellyseerr:
|
Check out our dockerhub for instructions on how to install and run Jellyseerr:
|
||||||
https://hub.docker.com/r/fallenbagel/jellyseerr
|
https://hub.docker.com/r/fallenbagel/jellyseerr
|
||||||
|
|
||||||
|
## Preview
|
||||||
|
|
||||||
|
<img src="./public/preview.jpg">
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
- You can get support on [Discord](https://discord.gg/ckbvBtDJgC).
|
- You can get support on [Discord](https://discord.gg/ckbvBtDJgC).
|
||||||
- Bug reports and feature requests can be submitted via [GitHub Issues](https://github.com/sct/overseerr/issues).
|
- You can ask questions in the Help category of our [GitHub Discussions](https://github.com/fallenbagel/jellyseerr/discussions).
|
||||||
|
- Bug reports and feature requests can be submitted via [GitHub Issues](https://github.com/fallenbagel/jellyseerr/issues).
|
||||||
|
|
||||||
<!-- markdownlint-restore -->
|
## API Documentation
|
||||||
<!-- prettier-ignore-end -->
|
|
||||||
|
|
||||||
## Buy me a Coffee!
|
You can access the API documentation from your local Jellyseerr install at http://localhost:5055/api-docs
|
||||||
|
|
||||||
If you like jellyseerr and want to help maintain it, please buy me a coffee as it would help me out a lot!
|
## Community
|
||||||
|
|
||||||
[](https://www.buymeacoffee.com/fallen.bagel)
|
You can ask questions, share ideas, and more in [GitHub Discussions](https://github.com/fallenbagel/jellyseerr/discussions).
|
||||||
|
|
||||||
|
If you would like to chat with other members of our growing community, [join the Jellyseerr Discord server](https://discord.gg/ckbvBtDJgC)!
|
||||||
|
|
||||||
|
Our [Code of Conduct](https://github.com/fallenbagel/jellyseerr/blob/develop/CODE_OF_CONDUCT.md) applies to all Jellyseerr community channels.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
You can help improve Jellyseerr too! Check out our [Contribution Guide](https://github.com/fallenbagel/jellyseerr/blob/develop/CONTRIBUTING.md) to get started.
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ The primary motivation for starting this project was to have an incredibly perfo
|
|||||||
|
|
||||||
Overseerr is an ambitious project. We have already poured a lot of work into this, and have a lot more to do. We need your valuable feedback and help to find and fix bugs. Also, with Overseerr being an open-source project, anyone is welcome to contribute. Contribution includes building new features, patching bugs, translating the application, or even just writing documentation.
|
Overseerr is an ambitious project. We have already poured a lot of work into this, and have a lot more to do. We need your valuable feedback and help to find and fix bugs. Also, with Overseerr being an open-source project, anyone is welcome to contribute. Contribution includes building new features, patching bugs, translating the application, or even just writing documentation.
|
||||||
|
|
||||||
If you would like to contribute, please be sure to review our [contribution guidelines](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md).
|
If you would like to contribute, please be sure to review our [contribution guidelines](https://github.com/fallenbagel/jellyseerr/blob/develop/CONTRIBUTING.md).
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
- [Email](using-overseerr/notifications/email.md)
|
- [Email](using-overseerr/notifications/email.md)
|
||||||
- [Web Push](using-overseerr/notifications/webpush.md)
|
- [Web Push](using-overseerr/notifications/webpush.md)
|
||||||
- [Discord](using-overseerr/notifications/discord.md)
|
- [Discord](using-overseerr/notifications/discord.md)
|
||||||
|
- [Gotify](using-overseerr/notifications/gotify.md)
|
||||||
- [LunaSea](using-overseerr/notifications/lunasea.md)
|
- [LunaSea](using-overseerr/notifications/lunasea.md)
|
||||||
- [Pushbullet](using-overseerr/notifications/pushbullet.md)
|
- [Pushbullet](using-overseerr/notifications/pushbullet.md)
|
||||||
- [Pushover](using-overseerr/notifications/pushover.md)
|
- [Pushover](using-overseerr/notifications/pushover.md)
|
||||||
|
|||||||
@@ -145,8 +145,7 @@ location ^~ /overseerr {
|
|||||||
sub_filter '/android-' '/$app/android-';
|
sub_filter '/android-' '/$app/android-';
|
||||||
sub_filter '/apple-' '/$app/apple-';
|
sub_filter '/apple-' '/$app/apple-';
|
||||||
sub_filter '/favicon' '/$app/favicon';
|
sub_filter '/favicon' '/$app/favicon';
|
||||||
sub_filter '/logo_full.svg' '/$app/logo_full.svg';
|
sub_filter '/logo_' '/$app/logo_';
|
||||||
sub_filter '/logo_stacked.svg' '/$app/logo_stacked.svg';
|
|
||||||
sub_filter '/site.webmanifest' '/$app/site.webmanifest';
|
sub_filter '/site.webmanifest' '/$app/site.webmanifest';
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
# Third-Party Integrations
|
# Third-Party Integrations
|
||||||
|
|
||||||
{% hint style="warning" %}
|
{% 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 %}
|
{% endhint %}
|
||||||
|
|
||||||
- [Organizr](https://organizr.app/), a HTPC/homelab services organizer
|
- [Organizr](https://organizr.app/), a HTPC/homelab services organizer
|
||||||
- [Heimdall](https://github.com/linuxserver/Heimdall), an application dashboard and launcher
|
- [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
|
- [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
|
- [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
|
- [ha-overseerr](https://github.com/vaparr/ha-overseerr), a custom Home Assistant component
|
||||||
- [OverCLIrr](https://github.com/WillFantom/OverCLIrr), a command-line tool
|
- [OverCLIrr](https://github.com/WillFantom/OverCLIrr), a command-line tool
|
||||||
- [Overseerr Exporter](https://github.com/WillFantom/overseerr-exporter), a Prometheus exporter
|
- [Overseerr Exporter](https://github.com/WillFantom/overseerr-exporter), a Prometheus exporter
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
{% hint style="danger" %}
|
{% hint style="danger" %}
|
||||||
**Overseerr is currently in BETA.** If you would like to help test the bleeding edge, please use the image **`sctx/overseerr:develop`**!
|
**Overseerr is currently in BETA.** If you would like to help test the bleeding edge, please use the image **`fallenbagel/jellyseerr:develop`**!
|
||||||
{% endhint %}
|
{% endhint %}
|
||||||
|
|
||||||
{% hint style="info" %}
|
{% hint style="info" %}
|
||||||
@@ -10,8 +10,18 @@ After running Overseerr for the first time, configure it by visiting the web UI
|
|||||||
|
|
||||||
## Docker
|
## 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 %}
|
{% 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
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
@@ -21,14 +31,44 @@ docker run -d \
|
|||||||
-p 5055:5055 \
|
-p 5055:5055 \
|
||||||
-v /path/to/appdata/config:/app/config \
|
-v /path/to/appdata/config:/app/config \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
sctx/overseerr
|
fallenbagel/jellyseerr
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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 fallenbagel/jellyseerr
|
||||||
|
```
|
||||||
|
|
||||||
|
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 %}
|
{% 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
|
```yaml
|
||||||
---
|
---
|
||||||
@@ -36,7 +76,7 @@ version: '3'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
overseerr:
|
overseerr:
|
||||||
image: sctx/overseerr:latest
|
image: fallenbagel/jellyseerr:latest
|
||||||
container_name: overseerr
|
container_name: overseerr
|
||||||
environment:
|
environment:
|
||||||
- LOG_LEVEL=debug
|
- LOG_LEVEL=debug
|
||||||
@@ -48,47 +88,29 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
|
|
||||||
{% endtab %}
|
Then, start all services defined in the your Compose file:
|
||||||
|
|
||||||
{% 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" %}
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Stop the Overseerr container
|
docker-compose up -d
|
||||||
docker stop overseerr
|
```
|
||||||
|
|
||||||
# Remove the Overseerr container
|
**Updating:**
|
||||||
docker rm overseerr
|
|
||||||
|
|
||||||
# Pull the latest update
|
Pull the latest image:
|
||||||
docker pull sctx/overseerr
|
|
||||||
|
|
||||||
# Run the Overseerr container with the same parameters as before
|
```bash
|
||||||
docker run -d ...
|
docker-compose pull overseerr
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, restart all services defined in the Compose file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
{% endtab %}
|
{% endtab %}
|
||||||
{% endtabs %}
|
{% 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
|
## Unraid
|
||||||
|
|
||||||
1. Ensure you have the **Community Applications** plugin installed.
|
1. Ensure you have the **Community Applications** plugin installed.
|
||||||
@@ -121,7 +143,7 @@ or the Docker Desktop app:
|
|||||||
Then, create and start the Overseerr container:
|
Then, create and start the Overseerr container:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d -e LOG_LEVEL=debug -e TZ=Asia/Tokyo -p 5055:5055 -v "overseerr-data:/app/config" --restart unless-stopped sctx/overseerr
|
docker run -d --name overseerr -e LOG_LEVEL=debug -e TZ=Asia/Tokyo -p 5055:5055 -v "overseerr-data:/app/config" --restart unless-stopped fallenbagel/jellyseerr:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
If using a named volume like above, you can safely ignore the warning about the `/app/config` folder being incorrectly mounted on the setup page.
|
If using a named volume like above, you can safely ignore the warning about the `/app/config` folder being incorrectly mounted on the setup page.
|
||||||
@@ -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).
|
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 %}
|
{% endhint %}
|
||||||
|
|
||||||
**To install:**
|
**Installation:**
|
||||||
|
|
||||||
```
|
```
|
||||||
sudo snap install overseerr
|
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:**
|
**Updating:**
|
||||||
|
|
||||||
Snap will keep Overseerr up-to-date automatically. You can force a refresh by using the following command.
|
Snap will keep Overseerr up-to-date automatically. You can force a refresh by using the following command.
|
||||||
|
|
||||||
```
|
```bash
|
||||||
sudo snap refresh
|
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
|
## Third-Party
|
||||||
|
|
||||||
{% tabs %}
|
{% tabs %}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Frequently Asked Questions (FAQ)
|
# Frequently Asked Questions (FAQ)
|
||||||
|
|
||||||
{% hint style="info" %}
|
{% hint style="info" %}
|
||||||
If you can't find the solution to your problem here, please read [Need Help?](./need-help.md) and reach out to us on [Discord](https://discord.gg/overseerr).
|
If you can't find the solution to your problem here, please read [Need Help?](./need-help.md) and reach out to us on [Discord](https://discord.gg/ckbvBtDJgC).
|
||||||
|
|
||||||
_Please do not post questions or support requests on the GitHub issue tracker!_
|
_Please do not post questions or support requests on the GitHub issue tracker!_
|
||||||
{% endhint %}
|
{% endhint %}
|
||||||
@@ -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`.
|
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?
|
### 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).
|
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).
|
||||||
@@ -28,7 +34,7 @@ You sure can! We are using [Weblate](https://hosted.weblate.org/engage/overseerr
|
|||||||
|
|
||||||
You can find the changelog for your version (stable/`latest`,s or `develop`) in the **Settings → About** page in your Overseerr instance.
|
You can find the changelog for your version (stable/`latest`,s or `develop`) in the **Settings → About** page in your Overseerr instance.
|
||||||
|
|
||||||
You can alternatively review the [stable release history](https://github.com/sct/overseerr/releases) and [`develop` branch commit history](https://github.com/sct/overseerr/commits/develop) on GitHub.
|
You can alternatively review the [stable release history](https://github.com/fallenbagel/jellyseerr/releases) and [`develop` branch commit history](https://github.com/fallenbagel/jellyseerr/commits/develop) on GitHub.
|
||||||
|
|
||||||
### Some media is missing from Overseerr that I know is in Plex!
|
### Some media is missing from Overseerr that I know is in Plex!
|
||||||
|
|
||||||
@@ -82,7 +88,7 @@ Yes! Please see the [documentation for creating local users](../using-overseerr/
|
|||||||
|
|
||||||
### Is is possible to set user roles in Overseerr?
|
### Is is possible to set user roles in Overseerr?
|
||||||
|
|
||||||
Permissions can be configured for each user via the **User List** or their **User Settings** page. The list of assignable permissions is still growing, so if you have any suggestions, [submit a feature request](https://github.com/sct/overseerr/issues/new/choose)!
|
Permissions can be configured for each user via the **User List** or their **User Settings** page. The list of assignable permissions is still growing, so if you have any suggestions, [submit a feature request](https://github.com/fallenbagel/jellyseerr/issues/new/choose)!
|
||||||
|
|
||||||
## Requests
|
## Requests
|
||||||
|
|
||||||
@@ -112,10 +118,16 @@ If you configured a URL base in Sonarr, make sure you have also configured the [
|
|||||||
|
|
||||||
Also, check that you are using Sonarr v3 and that you have configured a default language profile in Overseerr.
|
Also, check that you are using Sonarr v3 and that you have configured a default language profile in Overseerr.
|
||||||
|
|
||||||
Language profile support for Sonarr was added in [v1.20.0](https://github.com/sct/overseerr/releases/tag/v1.20.0) along with a new, _required_ **Language Profile** setting. If series requests are failing, make sure that you have a default language profile configured for each of your Sonarr servers in **Settings → Services**.
|
Language profile support for Sonarr was added in [v1.20.0](https://github.com/fallenbagel/jellyseerr/releases/tag/v1.20.0) along with a new, _required_ **Language Profile** setting. If series requests are failing, make sure that you have a default language profile configured for each of your Sonarr servers in **Settings → Services**.
|
||||||
|
|
||||||
## Notifications
|
## Notifications
|
||||||
|
|
||||||
### I am getting "Username and Password not accepted" when attempting to send email notifications via Gmail!
|
### 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).
|
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.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Before seeking assistance, please make sure you have first tried these following
|
|||||||
- **Analyzing** your logs, you just might find the solution yourself!
|
- **Analyzing** your logs, you just might find the solution yourself!
|
||||||
- **Searching** the [documentation](../README.md), [installation guide](../getting-started/installation.md), and [FAQs](./faq.md).
|
- **Searching** the [documentation](../README.md), [installation guide](../getting-started/installation.md), and [FAQs](./faq.md).
|
||||||
|
|
||||||
If you still have questions after troubleshooting on your own, feel free to ask on [Discord](https://discord.gg/overseerr)! (Please review our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md) before posting.)
|
If you still have questions after troubleshooting on your own, feel free to ask on [Discord](https://discord.gg/ckbvBtDJgC)! (Please review our [Code of Conduct](https://github.com/fallenbagel/jellyseerr/blob/develop/CODE_OF_CONDUCT.md) before posting.)
|
||||||
|
|
||||||
Be sure to also include a link to your logs. (Please see [How can I share my logs?](#how-can-i-share-my-logs) below.)
|
Be sure to also include a link to your logs. (Please see [How can I share my logs?](#how-can-i-share-my-logs) below.)
|
||||||
|
|
||||||
@@ -19,6 +19,11 @@ Please try to include as much information as possible. A vague statement like "i
|
|||||||
|
|
||||||
Try to answer the following questions:
|
Try to answer the following questions:
|
||||||
|
|
||||||
|
- What version of Overseerr are you running? (You can find this in Settings → About → Version.)
|
||||||
|
- How did you install Overseerr? Are you using the official Docker or snap images, or images published by a third-party?
|
||||||
|
- How are you accessing Overseerr?
|
||||||
|
- Are you accessing Overseerr through your reverse proxy or via a local IP address?
|
||||||
|
- What browser are you using? What browser extensions are enabled?
|
||||||
- What were you trying to do, and how did you attempt it?
|
- What were you trying to do, and how did you attempt it?
|
||||||
- What command did you enter?
|
- What command did you enter?
|
||||||
- What did you click on?
|
- What did you click on?
|
||||||
@@ -37,4 +42,4 @@ Try to answer the following questions:
|
|||||||
|
|
||||||
1. Locate the current log file at `<your Overseerr config directory>/logs/overseerr.log`.
|
1. Locate the current log file at `<your Overseerr config directory>/logs/overseerr.log`.
|
||||||
2. Open the log file and **copy its contents** into a [**secret gist** on GitHub](https://gist.github.com/). If you upload your logs elsewhere, we may ask you to share them again via GitHub Gist.
|
2. Open the log file and **copy its contents** into a [**secret gist** on GitHub](https://gist.github.com/). If you upload your logs elsewhere, we may ask you to share them again via GitHub Gist.
|
||||||
3. **Share the link/URL to your secret gist** in the [`#support` channel in our Discord server](https://discord.gg/overseerr).
|
3. **Share the link/URL to your secret gist** in the [`#support` channel in our Discord server](https://discord.gg/ckbvBtDJgC).
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Overseerr currently supports the following notification agents:
|
|||||||
- [Email](./email.md)
|
- [Email](./email.md)
|
||||||
- [Web Push](./webpush.md)
|
- [Web Push](./webpush.md)
|
||||||
- [Discord](./discord.md)
|
- [Discord](./discord.md)
|
||||||
|
- [Gotify](./gotify.md)
|
||||||
- [LunaSea](./lunasea.md)
|
- [LunaSea](./lunasea.md)
|
||||||
- [Pushbullet](./pushbullet.md)
|
- [Pushbullet](./pushbullet.md)
|
||||||
- [Pushover](./pushover.md)
|
- [Pushover](./pushover.md)
|
||||||
|
|||||||
15
docs/using-overseerr/notifications/gotify.md
Normal file
15
docs/using-overseerr/notifications/gotify.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Gotify
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Server URL
|
||||||
|
|
||||||
|
Set this to the URL of your Gotify server.
|
||||||
|
|
||||||
|
### Application Token
|
||||||
|
|
||||||
|
Add an application to your Gotify server, and set this field to the generated application token.
|
||||||
|
|
||||||
|
{% hint style="info" %}
|
||||||
|
Please refer to the [Gotify API documentation](https://gotify.net/docs) for more details on configuring these notifications.
|
||||||
|
{% endhint %}
|
||||||
@@ -1,7 +1,17 @@
|
|||||||
# Pushbullet
|
# 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
|
## Configuration
|
||||||
|
|
||||||
### Access Token
|
### Access Token
|
||||||
|
|
||||||
[Create an access token](https://www.pushbullet.com/#settings) and set it here to grant Overseerr access to the Pushbullet API.
|
[Create an access token](https://www.pushbullet.com/#settings) and set it here to grant Overseerr access to the Pushbullet API.
|
||||||
|
|
||||||
|
### Channel Tag (optional)
|
||||||
|
|
||||||
|
Optionally, [create a channel](https://www.pushbullet.com/my-channel) to allow other users to follow the notification feed using the specified channel tag.
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
# Pushover
|
# 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
|
## Configuration
|
||||||
|
|
||||||
### Application/API Token
|
### Application/API Token
|
||||||
|
|
||||||
[Register an application](https://pushover.net/apps/build) and enter the API token in this field. (You can use one of the [official icons in our GitHub repository](https://github.com/sct/overseerr/tree/develop/public) when configuring the application.)
|
[Register an application](https://pushover.net/apps/build) and enter the API token in this field. (You can use one of the [official icons in our GitHub repository](https://github.com/fallenbagel/jellyseerr/tree/develop/public) when configuring the application.)
|
||||||
|
|
||||||
For more details on registering applications or the API token, please see the [Pushover API documentation](https://pushover.net/api#registration).
|
For more details on registering applications or the API token, please see the [Pushover API documentation](https://pushover.net/api#registration).
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
# Telegram
|
# Telegram
|
||||||
|
|
||||||
{% hint style="info" %}
|
{% 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 %}
|
{% endhint %}
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|||||||
@@ -24,33 +24,38 @@ Customize the JSON payload to suit your needs. Overseerr provides several [templ
|
|||||||
|
|
||||||
### General
|
### General
|
||||||
|
|
||||||
- `{{notification_type}}` The type of notification. (Ex. `MEDIA_PENDING` or `MEDIA_APPROVED`)
|
| Variable | Value |
|
||||||
- `{{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)
|
| `{{notification_type}}` | The type of notification (e.g. `MEDIA_PENDING` or `ISSUE_COMMENT`) |
|
||||||
- `{{image}}` Associated image with the request. (For request notifications, this is the media's poster)
|
| `{{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.
|
These variables are for the target recipient of the notification.
|
||||||
|
|
||||||
- `{{notifyuser_username}}` Target user's username.
|
| Variable | Value |
|
||||||
- `{{notifyuser_email}}` Target user's email address.
|
| ---------------------------------------- | ------------------------------------------------------------- |
|
||||||
- `{{notifyuser_avatar}}` Target user's avatar URL.
|
| `{{notifyuser_username}}` | The target notification recipient's username |
|
||||||
- `{{notifyuser_settings_discordId}}` Target user's Discord ID (if one is set).
|
| `{{notifyuser_email}}` | The target notification recipient's email address |
|
||||||
- `{{notifyuser_settings_telegramChatId}}` Target user's Telegram Chat ID (if one is set).
|
| `{{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" %}
|
{% 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
|
- Request Pending Approval
|
||||||
- Media Automatically Approved
|
- Request Automatically Approved
|
||||||
- Media Failed
|
- Request Processing Failed
|
||||||
|
|
||||||
On the other hand, the `notifyuser` variables _will_ be replaced with the requesting user's information for the below notification types:
|
On the other hand, the `notifyuser` variables _will_ be replaced with the requesting user's information for the below notification types:
|
||||||
|
|
||||||
- Media Approved
|
- Request Approved
|
||||||
- Media Declined
|
- Request Declined
|
||||||
- Media Available
|
- Request Available
|
||||||
|
|
||||||
If you would like to use the requesting user's information in your webhook, please instead include the relevant variables from the [Request](#request) section below.
|
If you would like to use the requesting user's information in your webhook, please instead include the relevant variables from the [Request](#request) section below.
|
||||||
{% endhint %}
|
{% endhint %}
|
||||||
@@ -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}}": []`).
|
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.
|
| Variable | Value |
|
||||||
- `{{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.
|
| `{{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
|
#### 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`).
|
These following special variables are only included in media-related notifications, such as requests.
|
||||||
- `{{media_tmdbid}}` Media's TMDb ID.
|
|
||||||
- `{{media_imdbid}}` Media's IMDb ID.
|
| Variable | Value |
|
||||||
- `{{media_tvdbid}}` Media's TVDB ID.
|
| -------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||||
- `{{media_status}}` Media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`).
|
| `{{media_type}}` | The media type (`movie` or `tv`) |
|
||||||
- `{{media_status4k}}` Media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`)
|
| `{{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
|
#### 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.
|
The following special variables are only included in request-related notifications.
|
||||||
- `{{requestedBy_username}}` Requesting user's username.
|
|
||||||
- `{{requestedBy_email}}` Requesting user's email address.
|
| Variable | Value |
|
||||||
- `{{requestedBy_avatar}}` Requesting user's avatar URL.
|
| ----------------------------------------- | ----------------------------------------------- |
|
||||||
- `{{requestedBy_settings_discordId}}` Requesting user's Discord ID (if set).
|
| `{{request_id}}` | The request ID |
|
||||||
- `{{requestedBy_settings_telegramChatId}}` Requesting user's Telegram Chat ID (if set).
|
| `{{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) |
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ The user account created during Overseerr setup is the "Owner" account, which ca
|
|||||||
|
|
||||||
There are currently two methods to add users to Overseerr: importing Plex users and creating "local users." All new users are created with the [default permissions](../settings/README.md#default-permissions) defined in **Settings → Users**.
|
There are currently two methods to add users to Overseerr: importing Plex users and creating "local users." All new users are created with the [default permissions](../settings/README.md#default-permissions) defined in **Settings → Users**.
|
||||||
|
|
||||||
### Importing Users from Plex
|
### Importing Plex Users
|
||||||
|
|
||||||
Clicking the **Import Users from Plex** button on the **User List** page will fetch the list of users with access to the Plex server from [plex.tv](https://www.plex.tv/), and add them to Overseerr automatically.
|
Clicking the **Import Plex Users** button on the **User List** page will fetch the list of users with access to the Plex server from [plex.tv](https://www.plex.tv/), and add them to Overseerr automatically.
|
||||||
|
|
||||||
Importing Plex users is not required, however. Any user with access to the Plex server can log in to Overseerr even if they have not been imported, and will be assigned the configured [default permissions](../settings/README.md#default-permissions) upon their first login.
|
Importing Plex users is not required, however. Any user with access to the Plex server can log in to Overseerr even if they have not been imported, and will be assigned the configured [default permissions](../settings/README.md#default-permissions) upon their first login.
|
||||||
|
|
||||||
|
|||||||
1
next-env.d.ts
vendored
1
next-env.d.ts
vendored
@@ -1,5 +1,4 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/types/global" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
|
|||||||
@@ -171,6 +171,9 @@ components:
|
|||||||
port:
|
port:
|
||||||
type: number
|
type: number
|
||||||
example: 32400
|
example: 32400
|
||||||
|
useSsl:
|
||||||
|
type: boolean
|
||||||
|
nullable: true
|
||||||
libraries:
|
libraries:
|
||||||
type: array
|
type: array
|
||||||
readOnly: true
|
readOnly: true
|
||||||
@@ -178,6 +181,7 @@ components:
|
|||||||
$ref: '#/components/schemas/PlexLibrary'
|
$ref: '#/components/schemas/PlexLibrary'
|
||||||
webAppUrl:
|
webAppUrl:
|
||||||
type: string
|
type: string
|
||||||
|
nullable: true
|
||||||
example: 'https://app.plex.tv/desktop'
|
example: 'https://app.plex.tv/desktop'
|
||||||
required:
|
required:
|
||||||
- name
|
- name
|
||||||
@@ -329,6 +333,9 @@ components:
|
|||||||
hostname:
|
hostname:
|
||||||
type: string
|
type: string
|
||||||
example: 'http://my.jellyfin.host'
|
example: 'http://my.jellyfin.host'
|
||||||
|
externalHostname:
|
||||||
|
type: string
|
||||||
|
example: 'http://my.jellyfin.host'
|
||||||
adminUser:
|
adminUser:
|
||||||
type: string
|
type: string
|
||||||
example: 'admin'
|
example: 'admin'
|
||||||
@@ -343,8 +350,26 @@ components:
|
|||||||
serverID:
|
serverID:
|
||||||
type: string
|
type: string
|
||||||
readOnly: true
|
readOnly: true
|
||||||
required:
|
TautulliSettings:
|
||||||
- hostname
|
type: object
|
||||||
|
properties:
|
||||||
|
hostname:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
example: 'tautulli.example.com'
|
||||||
|
port:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
example: 8181
|
||||||
|
useSsl:
|
||||||
|
type: boolean
|
||||||
|
nullable: true
|
||||||
|
apiKey:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
externalUrl:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
RadarrSettings:
|
RadarrSettings:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -956,6 +981,15 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/ProductionCompany'
|
$ref: '#/components/schemas/ProductionCompany'
|
||||||
|
productionCountries:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
iso_3166_1:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
spokenLanguages:
|
spokenLanguages:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -1176,6 +1210,8 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
webhookUrl:
|
webhookUrl:
|
||||||
type: string
|
type: string
|
||||||
|
enableMentions:
|
||||||
|
type: boolean
|
||||||
SlackSettings:
|
SlackSettings:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -1251,6 +1287,9 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
accessToken:
|
accessToken:
|
||||||
type: string
|
type: string
|
||||||
|
channelTag:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
PushoverSettings:
|
PushoverSettings:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -1267,6 +1306,22 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
userToken:
|
userToken:
|
||||||
type: string
|
type: string
|
||||||
|
GotifySettings:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
example: false
|
||||||
|
types:
|
||||||
|
type: number
|
||||||
|
example: 2
|
||||||
|
options:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
LunaSeaSettings:
|
LunaSeaSettings:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -1325,7 +1380,28 @@ components:
|
|||||||
allowSelfSigned:
|
allowSelfSigned:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: false
|
example: false
|
||||||
PersonDetail:
|
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
|
||||||
|
PersonDetails:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
@@ -1656,6 +1732,15 @@ components:
|
|||||||
discordId:
|
discordId:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
|
pushbulletAccessToken:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
pushoverApplicationToken:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
pushoverUserKey:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
telegramEnabled:
|
telegramEnabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
telegramBotUsername:
|
telegramBotUsername:
|
||||||
@@ -1713,6 +1798,36 @@ components:
|
|||||||
type: number
|
type: number
|
||||||
name:
|
name:
|
||||||
type: string
|
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:
|
securitySchemes:
|
||||||
cookieAuth:
|
cookieAuth:
|
||||||
type: apiKey
|
type: apiKey
|
||||||
@@ -1870,6 +1985,20 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/JellyfinLibrary'
|
$ref: '#/components/schemas/JellyfinLibrary'
|
||||||
|
/settings/jellyfin/users:
|
||||||
|
get:
|
||||||
|
summary: Get Jellyfin Users
|
||||||
|
description: Returns a list of Jellyfin Users in a JSON array.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
- users
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Jellyfin users returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
/settings/jellyfin/sync:
|
/settings/jellyfin/sync:
|
||||||
get:
|
get:
|
||||||
summary: Get status of full Jellyfin library sync
|
summary: Get status of full Jellyfin library sync
|
||||||
@@ -2084,6 +2213,67 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/PlexDevice'
|
$ref: '#/components/schemas/PlexDevice'
|
||||||
|
/settings/plex/users:
|
||||||
|
get:
|
||||||
|
summary: Get Plex users
|
||||||
|
description: |
|
||||||
|
Returns a list of Plex users in a JSON array.
|
||||||
|
|
||||||
|
Requires the `MANAGE_USERS` permission.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
- users
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Plex users
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
thumb:
|
||||||
|
type: string
|
||||||
|
/settings/tautulli:
|
||||||
|
get:
|
||||||
|
summary: Get Tautulli settings
|
||||||
|
description: Retrieves current Tautulli settings.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TautulliSettings'
|
||||||
|
post:
|
||||||
|
summary: Update Tautulli settings
|
||||||
|
description: Updates Tautulli settings with the provided values.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TautulliSettings'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 'Values were successfully updated'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TautulliSettings'
|
||||||
/settings/radarr:
|
/settings/radarr:
|
||||||
get:
|
get:
|
||||||
summary: Get Radarr settings
|
summary: Get Radarr settings
|
||||||
@@ -2391,23 +2581,7 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: object
|
$ref: '#/components/schemas/Job'
|
||||||
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
|
|
||||||
/settings/jobs/{jobId}/run:
|
/settings/jobs/{jobId}/run:
|
||||||
post:
|
post:
|
||||||
summary: Invoke a specific job
|
summary: Invoke a specific job
|
||||||
@@ -2426,23 +2600,7 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
$ref: '#/components/schemas/Job'
|
||||||
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
|
|
||||||
/settings/jobs/{jobId}/cancel:
|
/settings/jobs/{jobId}/cancel:
|
||||||
post:
|
post:
|
||||||
summary: Cancel a specific job
|
summary: Cancel a specific job
|
||||||
@@ -2461,23 +2619,36 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
$ref: '#/components/schemas/Job'
|
||||||
properties:
|
/settings/jobs/{jobId}/schedule:
|
||||||
id:
|
post:
|
||||||
type: string
|
summary: Modify job schedule
|
||||||
example: job-name
|
description: Re-registers the job with the schedule specified. Will return the job in JSON format.
|
||||||
type:
|
tags:
|
||||||
type: string
|
- settings
|
||||||
enum: [process, command]
|
parameters:
|
||||||
name:
|
- in: path
|
||||||
type: string
|
name: jobId
|
||||||
example: A Job Name
|
required: true
|
||||||
nextExecutionTime:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
example: '2020-09-02T05:02:23.000Z'
|
requestBody:
|
||||||
running:
|
required: true
|
||||||
type: boolean
|
content:
|
||||||
example: false
|
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:
|
/settings/cache:
|
||||||
get:
|
get:
|
||||||
summary: Get a list of active caches
|
summary: Get a list of active caches
|
||||||
@@ -2575,7 +2746,7 @@ paths:
|
|||||||
example: Server ready on port 5055
|
example: Server ready on port 5055
|
||||||
timestamp:
|
timestamp:
|
||||||
type: string
|
type: string
|
||||||
example: 2020-12-15T16:20:00.069Z
|
example: '2020-12-15T16:20:00.069Z'
|
||||||
/settings/notifications/email:
|
/settings/notifications/email:
|
||||||
get:
|
get:
|
||||||
summary: Get email notification settings
|
summary: Get email notification settings
|
||||||
@@ -2806,6 +2977,52 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'204':
|
'204':
|
||||||
description: Test notification attempted
|
description: Test notification attempted
|
||||||
|
/settings/notifications/gotify:
|
||||||
|
get:
|
||||||
|
summary: Get Gotify notification settings
|
||||||
|
description: Returns current Gotify notification settings in a JSON object.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Returned Gotify settings
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GotifySettings'
|
||||||
|
post:
|
||||||
|
summary: Update Gotify notification settings
|
||||||
|
description: Update Gotify notification settings with the provided values.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GotifySettings'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 'Values were sucessfully updated'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GotifySettings'
|
||||||
|
/settings/notifications/gotify/test:
|
||||||
|
post:
|
||||||
|
summary: Test Gotify settings
|
||||||
|
description: Sends a test notification to the Gotify agent.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GotifySettings'
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Test notification attempted
|
||||||
/settings/notifications/slack:
|
/settings/notifications/slack:
|
||||||
get:
|
get:
|
||||||
summary: Get Slack notification settings
|
summary: Get Slack notification settings
|
||||||
@@ -3017,6 +3234,9 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
example: Asia/Tokyo
|
example: Asia/Tokyo
|
||||||
|
appDataPath:
|
||||||
|
type: string
|
||||||
|
example: /app/config
|
||||||
/auth/me:
|
/auth/me:
|
||||||
get:
|
get:
|
||||||
summary: Get logged-in user
|
summary: Get logged-in user
|
||||||
@@ -3169,6 +3389,13 @@ paths:
|
|||||||
security: []
|
security: []
|
||||||
tags:
|
tags:
|
||||||
- users
|
- users
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: guid
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: OK
|
description: OK
|
||||||
@@ -3291,11 +3518,51 @@ paths:
|
|||||||
post:
|
post:
|
||||||
summary: Import all users from Plex
|
summary: Import all users from Plex
|
||||||
description: |
|
description: |
|
||||||
Requests users from the Plex Server and creates a new user for each of them
|
Fetches and imports users from the Plex server. If a list of Plex IDs is provided in the request body, only the specified users will be imported. Otherwise, all users will be imported.
|
||||||
|
|
||||||
Requires the `MANAGE_USERS` permission.
|
Requires the `MANAGE_USERS` permission.
|
||||||
tags:
|
tags:
|
||||||
- users
|
- users
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
plexIds:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: A list of the newly created users
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/User'
|
||||||
|
/user/import-from-jellyfin:
|
||||||
|
post:
|
||||||
|
summary: Import all users from Jellyfin
|
||||||
|
description: |
|
||||||
|
Fetches and imports users from the Jellyfin server.
|
||||||
|
|
||||||
|
Requires the `MANAGE_USERS` permission.
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
jellyfinIds:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: A list of the newly created users
|
description: A list of the newly created users
|
||||||
@@ -3697,6 +3964,35 @@ paths:
|
|||||||
permissions:
|
permissions:
|
||||||
type: number
|
type: number
|
||||||
example: 2
|
example: 2
|
||||||
|
/user/{userId}/watch_data:
|
||||||
|
get:
|
||||||
|
summary: Get watch data
|
||||||
|
description: |
|
||||||
|
Returns play count, play duration, and recently watched media.
|
||||||
|
|
||||||
|
Requires the `ADMIN` permission to fetch results for other users.
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: userId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Users
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
recentlyWatched:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/MediaInfo'
|
||||||
|
playCount:
|
||||||
|
type: number
|
||||||
/search:
|
/search:
|
||||||
get:
|
get:
|
||||||
summary: Search for movies, TV shows, or people
|
summary: Search for movies, TV shows, or people
|
||||||
@@ -4476,21 +4772,22 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
total:
|
||||||
|
type: number
|
||||||
|
movie:
|
||||||
|
type: number
|
||||||
|
tv:
|
||||||
|
type: number
|
||||||
pending:
|
pending:
|
||||||
type: number
|
type: number
|
||||||
example: 0
|
|
||||||
approved:
|
approved:
|
||||||
type: number
|
type: number
|
||||||
example: 10
|
declined:
|
||||||
|
type: number
|
||||||
processing:
|
processing:
|
||||||
type: number
|
type: number
|
||||||
example: 4
|
|
||||||
available:
|
available:
|
||||||
type: number
|
type: number
|
||||||
example: 6
|
|
||||||
required:
|
|
||||||
- pending
|
|
||||||
- approved
|
|
||||||
/request/{requestId}:
|
/request/{requestId}:
|
||||||
get:
|
get:
|
||||||
summary: Get MediaRequest
|
summary: Get MediaRequest
|
||||||
@@ -4966,8 +5263,7 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/PersonDetail'
|
$ref: '#/components/schemas/PersonDetails'
|
||||||
|
|
||||||
/person/{personId}/combined_credits:
|
/person/{personId}/combined_credits:
|
||||||
get:
|
get:
|
||||||
summary: Get combined credits
|
summary: Get combined credits
|
||||||
@@ -5104,6 +5400,57 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/MediaInfo'
|
$ref: '#/components/schemas/MediaInfo'
|
||||||
|
/media/{mediaId}/watch_data:
|
||||||
|
get:
|
||||||
|
summary: Get watch data
|
||||||
|
description: |
|
||||||
|
Returns play count, play duration, and users who have watched the media.
|
||||||
|
|
||||||
|
Requires the `ADMIN` permission.
|
||||||
|
tags:
|
||||||
|
- media
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: mediaId
|
||||||
|
description: Media ID
|
||||||
|
required: true
|
||||||
|
example: '1'
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Users
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
playCount7Days:
|
||||||
|
type: number
|
||||||
|
playCount30Days:
|
||||||
|
type: number
|
||||||
|
playCount:
|
||||||
|
type: number
|
||||||
|
users:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/User'
|
||||||
|
data4k:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
playCount7Days:
|
||||||
|
type: number
|
||||||
|
playCount30Days:
|
||||||
|
type: number
|
||||||
|
playCount:
|
||||||
|
type: number
|
||||||
|
users:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/User'
|
||||||
/collection/{collectionId}:
|
/collection/{collectionId}:
|
||||||
get:
|
get:
|
||||||
summary: Get collection details
|
summary: Get collection details
|
||||||
@@ -5374,7 +5721,267 @@ paths:
|
|||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
example: Drama
|
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:
|
security:
|
||||||
- cookieAuth: []
|
- cookieAuth: []
|
||||||
- apiKey: []
|
- apiKey: []
|
||||||
|
|||||||
168
package.json
168
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jellyseerr",
|
"name": "jellyseerr",
|
||||||
"version": "1.0.2",
|
"version": "1.29.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"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",
|
"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,155 +10,151 @@
|
|||||||
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\"",
|
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\"",
|
||||||
"start": "NODE_ENV=production node dist/index.js",
|
"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}\"",
|
"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:generate": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate",
|
||||||
"migration:create": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:create",
|
"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/.bin/typeorm migration:run",
|
"migration:run": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run",
|
||||||
"format": "prettier --write ."
|
"format": "prettier --write .",
|
||||||
|
"prepare": "husky install"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/fallenbagel/jellyseerr.git"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^1.4.1",
|
"@headlessui/react": "^1.5.0",
|
||||||
"@heroicons/react": "^1.0.4",
|
"@heroicons/react": "^1.0.6",
|
||||||
"@supercharge/request-ip": "^1.1.2",
|
"@supercharge/request-ip": "^1.2.0",
|
||||||
"@svgr/webpack": "^5.5.0",
|
"@svgr/webpack": "^6.2.1",
|
||||||
"@tanem/react-nprogress": "^3.0.79",
|
"@tanem/react-nprogress": "^4.0.10",
|
||||||
"ace-builds": "^1.4.12",
|
"ace-builds": "^1.4.14",
|
||||||
"axios": "^0.21.4",
|
"axios": "^0.26.1",
|
||||||
"bcrypt": "^5.0.1",
|
"bcrypt": "^5.0.1",
|
||||||
"bowser": "^2.11.0",
|
"bowser": "^2.11.0",
|
||||||
"connect-typeorm": "^1.1.4",
|
"connect-typeorm": "^1.1.4",
|
||||||
"cookie-parser": "^1.4.5",
|
"cookie-parser": "^1.4.6",
|
||||||
"copy-to-clipboard": "^3.3.1",
|
"copy-to-clipboard": "^3.3.1",
|
||||||
"country-flag-icons": "^1.4.10",
|
"country-flag-icons": "^1.4.21",
|
||||||
"csurf": "^1.11.0",
|
"csurf": "^1.11.0",
|
||||||
"email-templates": "^8.0.8",
|
"email-templates": "^8.0.10",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.3",
|
||||||
"express-openapi-validator": "^4.13.1",
|
"express-openapi-validator": "^4.13.6",
|
||||||
"express-rate-limit": "^5.3.0",
|
"express-rate-limit": "^6.3.0",
|
||||||
"express-session": "^1.17.2",
|
"express-session": "^1.17.2",
|
||||||
"formik": "^2.2.9",
|
"formik": "^2.2.9",
|
||||||
"gravatar-url": "3.1.0",
|
"gravatar-url": "^3.1.0",
|
||||||
"intl": "^1.2.5",
|
"intl": "^1.2.5",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"next": "11.1.2",
|
"next": "12.1.0",
|
||||||
"node-cache": "^5.1.2",
|
"node-cache": "^5.1.2",
|
||||||
"node-schedule": "^2.0.0",
|
"node-gyp": "^9.0.0",
|
||||||
"nodemailer": "^6.6.3",
|
"node-schedule": "^2.1.0",
|
||||||
"openpgp": "^5.0.0-3",
|
"nodemailer": "^6.7.2",
|
||||||
"plex-api": "^5.3.1",
|
"openpgp": "^5.2.0",
|
||||||
|
"plex-api": "^5.3.2",
|
||||||
"pug": "^3.0.2",
|
"pug": "^3.0.2",
|
||||||
"react": "17.0.2",
|
"react": "17.0.2",
|
||||||
"react-ace": "^9.3.0",
|
"react-ace": "^9.5.0",
|
||||||
"react-animate-height": "^2.0.23",
|
"react-animate-height": "^2.0.23",
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "17.0.2",
|
||||||
"react-intersection-observer": "^8.32.1",
|
"react-intersection-observer": "^8.33.1",
|
||||||
"react-intl": "5.20.10",
|
"react-intl": "5.24.7",
|
||||||
"react-markdown": "^6.0.2",
|
"react-markdown": "^8.0.0",
|
||||||
"react-select": "^4.3.1",
|
"react-select": "^5.2.2",
|
||||||
"react-spring": "^9.2.4",
|
"react-spring": "^9.4.4",
|
||||||
"react-toast-notifications": "^2.5.1",
|
"react-toast-notifications": "^2.5.1",
|
||||||
"react-transition-group": "^4.4.2",
|
"react-transition-group": "^4.4.2",
|
||||||
"react-truncate-markup": "^5.1.0",
|
"react-truncate-markup": "^5.1.0",
|
||||||
"react-use-clipboard": "1.0.7",
|
"react-use-clipboard": "1.0.7",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"secure-random-password": "^0.2.3",
|
"secure-random-password": "^0.2.3",
|
||||||
|
"semver": "^7.3.5",
|
||||||
"sqlite3": "^5.0.2",
|
"sqlite3": "^5.0.2",
|
||||||
"swagger-ui-express": "^4.1.6",
|
"swagger-ui-express": "^4.3.0",
|
||||||
"swr": "^0.5.6",
|
"swr": "^1.2.2",
|
||||||
"typeorm": "0.2.37",
|
"typeorm": "0.2.45",
|
||||||
"uuid": "^8.3.2",
|
|
||||||
"web-push": "^3.4.5",
|
"web-push": "^3.4.5",
|
||||||
"winston": "^3.3.3",
|
"winston": "^3.6.0",
|
||||||
"winston-daily-rotate-file": "^4.5.5",
|
"winston-daily-rotate-file": "^4.6.1",
|
||||||
"xml2js": "^0.4.23",
|
"xml2js": "^0.4.23",
|
||||||
"yamljs": "^0.3.0",
|
"yamljs": "^0.3.0",
|
||||||
"yup": "^0.32.9"
|
"yup": "^0.32.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/cli": "^7.15.7",
|
"@babel/cli": "^7.17.6",
|
||||||
"@commitlint/cli": "^13.1.0",
|
"@commitlint/cli": "^16.2.1",
|
||||||
"@commitlint/config-conventional": "^13.1.0",
|
"@commitlint/config-conventional": "^16.2.1",
|
||||||
"@fullhuman/postcss-purgecss": "3.0.0",
|
"@semantic-release/changelog": "^6.0.1",
|
||||||
"@semantic-release/changelog": "^5.0.1",
|
"@semantic-release/commit-analyzer": "^9.0.2",
|
||||||
"@semantic-release/commit-analyzer": "^9.0.1",
|
"@semantic-release/exec": "^6.0.3",
|
||||||
"@semantic-release/exec": "^5.0.0",
|
"@semantic-release/git": "^10.0.1",
|
||||||
"@semantic-release/git": "^9.0.1",
|
"@tailwindcss/aspect-ratio": "^0.4.0",
|
||||||
"@tailwindcss/aspect-ratio": "^0.2.1",
|
"@tailwindcss/forms": "^0.5.0",
|
||||||
"@tailwindcss/forms": "^0.3.3",
|
"@tailwindcss/typography": "^0.5.2",
|
||||||
"@tailwindcss/typography": "^0.4.1",
|
|
||||||
"@types/bcrypt": "^5.0.0",
|
"@types/bcrypt": "^5.0.0",
|
||||||
"@types/cookie-parser": "^1.4.2",
|
"@types/cookie-parser": "^1.4.2",
|
||||||
"@types/country-flag-icons": "^1.2.0",
|
"@types/country-flag-icons": "^1.2.0",
|
||||||
"@types/csurf": "^1.11.2",
|
"@types/csurf": "^1.11.2",
|
||||||
"@types/email-templates": "^8.0.4",
|
"@types/email-templates": "^8.0.4",
|
||||||
"@types/express": "^4.17.13",
|
"@types/express": "^4.17.13",
|
||||||
"@types/express-rate-limit": "^5.1.3",
|
"@types/express-session": "^1.17.4",
|
||||||
"@types/express-session": "^1.17.3",
|
"@types/lodash": "^4.14.179",
|
||||||
"@types/lodash": "^4.14.173",
|
"@types/node": "^17.0.21",
|
||||||
"@types/node": "^15.6.1",
|
|
||||||
"@types/node-schedule": "^1.3.2",
|
"@types/node-schedule": "^1.3.2",
|
||||||
"@types/nodemailer": "^6.4.4",
|
"@types/nodemailer": "^6.4.4",
|
||||||
"@types/react": "^17.0.22",
|
"@types/react": "^17.0.40",
|
||||||
"@types/react-dom": "^17.0.9",
|
"@types/react-dom": "^17.0.13",
|
||||||
"@types/react-select": "^4.0.17",
|
"@types/react-transition-group": "^4.4.4",
|
||||||
"@types/react-toast-notifications": "^2.4.1",
|
|
||||||
"@types/react-transition-group": "^4.4.3",
|
|
||||||
"@types/secure-random-password": "^0.2.1",
|
"@types/secure-random-password": "^0.2.1",
|
||||||
|
"@types/semver": "^7.3.9",
|
||||||
"@types/swagger-ui-express": "^4.1.3",
|
"@types/swagger-ui-express": "^4.1.3",
|
||||||
"@types/uuid": "^8.3.1",
|
|
||||||
"@types/web-push": "^3.3.2",
|
"@types/web-push": "^3.3.2",
|
||||||
"@types/xml2js": "^0.4.9",
|
"@types/xml2js": "^0.4.9",
|
||||||
"@types/yamljs": "^0.2.31",
|
"@types/yamljs": "^0.2.31",
|
||||||
"@types/yup": "^0.29.13",
|
"@types/yup": "^0.29.13",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.31.1",
|
"@typescript-eslint/eslint-plugin": "^5.14.0",
|
||||||
"@typescript-eslint/parser": "^4.31.1",
|
"@typescript-eslint/parser": "^5.14.0",
|
||||||
"autoprefixer": "^10.3.4",
|
"autoprefixer": "^10.4.2",
|
||||||
"babel-plugin-react-intl": "^8.2.25",
|
"babel-plugin-react-intl": "^8.2.25",
|
||||||
"babel-plugin-react-intl-auto": "^3.3.0",
|
"babel-plugin-react-intl-auto": "^3.3.0",
|
||||||
"commitizen": "^4.2.4",
|
"commitizen": "^4.2.4",
|
||||||
"copyfiles": "^2.4.1",
|
"copyfiles": "^2.4.1",
|
||||||
"cz-conventional-changelog": "^3.3.0",
|
"cz-conventional-changelog": "^3.3.0",
|
||||||
"eslint": "^7.32.0",
|
"eslint": "^8.11.0",
|
||||||
"eslint-config-next": "^11.1.2",
|
"eslint-config-next": "^12.1.0",
|
||||||
"eslint-config-prettier": "^8.3.0",
|
"eslint-config-prettier": "^8.5.0",
|
||||||
"eslint-plugin-formatjs": "^2.17.6",
|
"eslint-plugin-formatjs": "^3.0.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||||
"eslint-plugin-prettier": "^4.0.0",
|
"eslint-plugin-prettier": "^4.0.0",
|
||||||
"eslint-plugin-react": "^7.25.3",
|
"eslint-plugin-react": "^7.29.3",
|
||||||
"eslint-plugin-react-hooks": "^4.2.0",
|
"eslint-plugin-react-hooks": "^4.3.0",
|
||||||
"extract-react-intl-messages": "^4.1.1",
|
"extract-react-intl-messages": "^4.1.1",
|
||||||
"husky": "4.3.8",
|
"husky": "^7.0.4",
|
||||||
"lint-staged": "^11.1.2",
|
"lint-staged": "^12.3.5",
|
||||||
"nodemon": "^2.0.12",
|
"nodemon": "^2.0.15",
|
||||||
"postcss": "^8.3.6",
|
"postcss": "^8.4.8",
|
||||||
"prettier": "^2.4.1",
|
"prettier": "^2.5.1",
|
||||||
"semantic-release": "^18.0.0",
|
"prettier-plugin-tailwindcss": "^0.1.8",
|
||||||
|
"semantic-release": "^19.0.2",
|
||||||
"semantic-release-docker-buildx": "^1.0.1",
|
"semantic-release-docker-buildx": "^1.0.1",
|
||||||
"tailwindcss": "^2.2.15",
|
"tailwindcss": "^3.0.23",
|
||||||
"ts-node": "^10.2.1",
|
"ts-node": "^10.7.0",
|
||||||
"typescript": "^4.4.3"
|
"typescript": "^4.6.2"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"sqlite3/node-gyp": "^5.1.0"
|
"sqlite3/node-gyp": "^8.4.1"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"commitizen": {
|
"commitizen": {
|
||||||
"path": "./node_modules/cz-conventional-changelog"
|
"path": "./node_modules/cz-conventional-changelog"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"husky": {
|
|
||||||
"hooks": {
|
|
||||||
"pre-commit": "lint-staged",
|
|
||||||
"prepare-commit-msg": "exec < /dev/tty && git cz --hook || true",
|
|
||||||
"commit-msg": "[[ -n $HUSKY_BYPASS ]] || commitlint -E HUSKY_GIT_PARAMS"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"**/*.{ts,tsx,js}": [
|
"**/*.{ts,tsx,js}": [
|
||||||
"prettier --write",
|
"prettier --write",
|
||||||
"eslint"
|
"eslint"
|
||||||
],
|
],
|
||||||
"**/*.{json,md}": [
|
"**/*.{json,md,css}": [
|
||||||
"prettier --write"
|
"prettier --write"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
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 |
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#1f2937" />
|
||||||
|
|
||||||
<title>You are offline</title>
|
<title>You are offline</title>
|
||||||
|
|
||||||
|
|||||||
14
public/sw.js
14
public/sw.js
@@ -90,8 +90,8 @@ self.addEventListener('push', (event) => {
|
|||||||
if (payload.actionUrl){
|
if (payload.actionUrl){
|
||||||
options.actions.push(
|
options.actions.push(
|
||||||
{
|
{
|
||||||
action: 'viewmedia',
|
action: 'view',
|
||||||
title: 'View Media',
|
title: payload.actionUrlTitle ?? 'View',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -119,21 +119,17 @@ self.addEventListener('notificationclick', (event) => {
|
|||||||
|
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
|
|
||||||
if (event.action === 'viewmedia') {
|
if (event.action === 'approve') {
|
||||||
clients.openWindow(notificationData.actionUrl);
|
|
||||||
} else if (event.action === 'approve') {
|
|
||||||
fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
|
fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
|
|
||||||
clients.openWindow(notificationData.actionUrl);
|
|
||||||
} else if (event.action === 'decline') {
|
} else if (event.action === 'decline') {
|
||||||
fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
|
fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
clients.openWindow(notificationData.actionUrl);
|
if (notificationData.actionUrl) {
|
||||||
} else if (notificationData.actionUrl) {
|
|
||||||
clients.openWindow(notificationData.actionUrl);
|
clients.openWindow(notificationData.actionUrl);
|
||||||
}
|
}
|
||||||
}, false);
|
}, false);
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class GithubAPI extends ExternalAPI {
|
|||||||
} = {}): Promise<GitHubRelease[]> {
|
} = {}): Promise<GitHubRelease[]> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<GitHubRelease[]>(
|
const data = await this.get<GitHubRelease[]>(
|
||||||
'/repos/Fallenbagel/jellyseerr/releases',
|
'/repos/fallenbagel/jellyseerr/releases',
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
per_page: take,
|
per_page: take,
|
||||||
@@ -110,7 +110,7 @@ class GithubAPI extends ExternalAPI {
|
|||||||
} = {}): Promise<GithubCommit[]> {
|
} = {}): Promise<GithubCommit[]> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<GithubCommit[]>(
|
const data = await this.get<GithubCommit[]>(
|
||||||
'/repos/Fallenbagel/jellyseerr/commits',
|
'/repos/fallenbagel/jellyseerr/commits',
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
per_page: take,
|
per_page: take,
|
||||||
@@ -122,7 +122,7 @@ class GithubAPI extends ExternalAPI {
|
|||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
"Failed to retrieve GitHub commits. This may be an issue on GitHub's end. Jellyseerr can't check if it's on the latest version.",
|
"Failed to retrieve GitHub commits. This may be an issue on GitHub's end. Overseerr can't check if it's on the latest version.",
|
||||||
{ label: 'GitHub API', errorMessage: e.message }
|
{ label: 'GitHub API', errorMessage: e.message }
|
||||||
);
|
);
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export interface JellyfinLoginResponse {
|
|||||||
AccessToken: string;
|
AccessToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JellyfinUserListResponse {
|
||||||
|
users: Array<JellyfinUserResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface JellyfinLibrary {
|
export interface JellyfinLibrary {
|
||||||
type: 'show' | 'movie';
|
type: 'show' | 'movie';
|
||||||
key: string;
|
key: string;
|
||||||
@@ -81,9 +85,9 @@ class JellyfinAPI {
|
|||||||
|
|
||||||
let authHeaderVal = '';
|
let authHeaderVal = '';
|
||||||
if (this.authToken) {
|
if (this.authToken) {
|
||||||
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Axios", DeviceId="${deviceId}", Version="10.8.0", Token="${authToken}"`;
|
authHeaderVal = `MediaBrowser Client="Overseerr", Device="Axios", DeviceId="${deviceId}", Version="10.8.0", Token="${authToken}"`;
|
||||||
} else {
|
} else {
|
||||||
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Axios", DeviceId="${deviceId}", Version="10.8.0"`;
|
authHeaderVal = `MediaBrowser Client="Overseerr", Device="Axios", DeviceId="${deviceId}", Version="10.8.0"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.axios = axios.create({
|
this.axios = axios.create({
|
||||||
@@ -122,7 +126,7 @@ class JellyfinAPI {
|
|||||||
public async getServerName(): Promise<string> {
|
public async getServerName(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const account = await this.axios.get<JellyfinUserResponse>(
|
const account = await this.axios.get<JellyfinUserResponse>(
|
||||||
`/System/Info/Public'}`
|
"/System/Info/Public'}"
|
||||||
);
|
);
|
||||||
return account.data.ServerName;
|
return account.data.ServerName;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -134,6 +138,19 @@ class JellyfinAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getUsers(): Promise<JellyfinUserListResponse> {
|
||||||
|
try {
|
||||||
|
const account = await this.axios.get(`/Users`);
|
||||||
|
return { users: account.data };
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
||||||
|
{ label: 'Jellyfin API' }
|
||||||
|
);
|
||||||
|
throw new Error('Invalid auth token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async getUser(): Promise<JellyfinUserResponse> {
|
public async getUser(): Promise<JellyfinUserResponse> {
|
||||||
try {
|
try {
|
||||||
const account = await this.axios.get<JellyfinUserResponse>(
|
const account = await this.axios.get<JellyfinUserResponse>(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import NodePlexAPI from 'plex-api';
|
import NodePlexAPI from 'plex-api';
|
||||||
import { getSettings, Library, PlexSettings } from '../lib/settings';
|
import { getSettings, Library, PlexSettings } from '../lib/settings';
|
||||||
|
import logger from '../logger';
|
||||||
|
|
||||||
export interface PlexLibraryItem {
|
export interface PlexLibraryItem {
|
||||||
ratingKey: string;
|
ratingKey: string;
|
||||||
@@ -122,9 +123,9 @@ class PlexAPI {
|
|||||||
// },
|
// },
|
||||||
options: {
|
options: {
|
||||||
identifier: settings.clientId,
|
identifier: settings.clientId,
|
||||||
product: 'Jellyseerr',
|
product: 'Overseerr',
|
||||||
deviceName: 'Jellyseerr',
|
deviceName: 'Overseerr',
|
||||||
platform: 'Jellyseerr',
|
platform: 'Overseerr',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -145,28 +146,40 @@ class PlexAPI {
|
|||||||
public async syncLibraries(): Promise<void> {
|
public async syncLibraries(): Promise<void> {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
const libraries = await this.getLibraries();
|
try {
|
||||||
|
const libraries = await this.getLibraries();
|
||||||
|
|
||||||
const newLibraries: Library[] = libraries
|
const newLibraries: Library[] = libraries
|
||||||
// Remove libraries that are not movie or show
|
// Remove libraries that are not movie or show
|
||||||
.filter((library) => library.type === 'movie' || library.type === 'show')
|
.filter(
|
||||||
// Remove libraries that do not have a metadata agent set (usually personal video libraries)
|
(library) => library.type === 'movie' || library.type === 'show'
|
||||||
.filter((library) => library.agent !== 'com.plexapp.agents.none')
|
)
|
||||||
.map((library) => {
|
// Remove libraries that do not have a metadata agent set (usually personal video libraries)
|
||||||
const existing = settings.plex.libraries.find(
|
.filter((library) => library.agent !== 'com.plexapp.agents.none')
|
||||||
(l) => l.id === library.key && l.name === library.title
|
.map((library) => {
|
||||||
);
|
const existing = settings.plex.libraries.find(
|
||||||
|
(l) => l.id === library.key && l.name === library.title
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: library.key,
|
id: library.key,
|
||||||
name: library.title,
|
name: library.title,
|
||||||
enabled: existing?.enabled ?? false,
|
enabled: existing?.enabled ?? false,
|
||||||
type: library.type,
|
type: library.type,
|
||||||
lastScan: existing?.lastScan,
|
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 = newLibraries;
|
settings.plex.libraries = [];
|
||||||
|
}
|
||||||
|
|
||||||
settings.save();
|
settings.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ class PlexTvAPI {
|
|||||||
|
|
||||||
const users = friends.MediaContainer.User;
|
const users = friends.MediaContainer.User;
|
||||||
|
|
||||||
const user = users.find((u) => Number(u.$.id) === userId);
|
const user = users.find((u) => parseInt(u.$.id) === userId);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -2,6 +2,35 @@ import cacheManager, { AvailableCacheIds } from '../../lib/cache';
|
|||||||
import { DVRSettings } from '../../lib/settings';
|
import { DVRSettings } from '../../lib/settings';
|
||||||
import ExternalAPI from '../externalapi';
|
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 {
|
export interface RootFolder {
|
||||||
id: number;
|
id: number;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -81,6 +110,18 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
|||||||
this.apiName = apiName;
|
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[]> => {
|
public getProfiles = async (): Promise<QualityProfile[]> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.getRolling<QualityProfile[]>(
|
const data = await this.getRolling<QualityProfile[]>(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import logger from '../../logger';
|
import logger from '../../logger';
|
||||||
import ServarrBase from './base';
|
import ServarrBase from './base';
|
||||||
|
|
||||||
interface RadarrMovieOptions {
|
export interface RadarrMovieOptions {
|
||||||
title: string;
|
title: string;
|
||||||
qualityProfileId: number;
|
qualityProfileId: number;
|
||||||
minimumAvailability: string;
|
minimumAvailability: string;
|
||||||
@@ -27,7 +27,6 @@ export interface RadarrMovie {
|
|||||||
profileId: number;
|
profileId: number;
|
||||||
qualityProfileId: number;
|
qualityProfileId: number;
|
||||||
added: string;
|
added: string;
|
||||||
downloaded: boolean;
|
|
||||||
hasFile: boolean;
|
hasFile: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +84,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
try {
|
try {
|
||||||
const movie = await this.getMovieByTmdbId(options.tmdbId);
|
const movie = await this.getMovieByTmdbId(options.tmdbId);
|
||||||
|
|
||||||
if (movie.downloaded) {
|
if (movie.hasFile) {
|
||||||
logger.info(
|
logger.info(
|
||||||
'Title already exists and is available. Skipping add and returning success',
|
'Title already exists and is available. Skipping add and returning success',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export interface SonarrSeries {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AddSeriesOptions {
|
export interface AddSeriesOptions {
|
||||||
tvdbid: number;
|
tvdbid: number;
|
||||||
title: string;
|
title: string;
|
||||||
profileId: number;
|
profileId: number;
|
||||||
@@ -149,6 +149,7 @@ class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
|
|||||||
|
|
||||||
// If the series already exists, we will simply just update it
|
// If the series already exists, we will simply just update it
|
||||||
if (series.id) {
|
if (series.id) {
|
||||||
|
series.monitored = options.monitored ?? series.monitored;
|
||||||
series.tags = options.tags ?? series.tags;
|
series.tags = options.tags ?? series.tags;
|
||||||
series.seasons = this.buildSeasonList(options.seasons, series.seasons);
|
series.seasons = this.buildSeasonList(options.seasons, series.seasons);
|
||||||
|
|
||||||
|
|||||||
293
server/api/tautulli.ts
Normal file
293
server/api/tautulli.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
import { uniqWith } from 'lodash';
|
||||||
|
import { User } from '../entity/User';
|
||||||
|
import { TautulliSettings } from '../lib/settings';
|
||||||
|
import logger from '../logger';
|
||||||
|
|
||||||
|
export interface TautulliHistoryRecord {
|
||||||
|
date: number;
|
||||||
|
duration: number;
|
||||||
|
friendly_name: string;
|
||||||
|
full_title: string;
|
||||||
|
grandparent_rating_key: number;
|
||||||
|
grandparent_title: string;
|
||||||
|
original_title: string;
|
||||||
|
group_count: number;
|
||||||
|
group_ids?: string;
|
||||||
|
guid: string;
|
||||||
|
ip_address: string;
|
||||||
|
live: number;
|
||||||
|
machine_id: string;
|
||||||
|
media_index: number;
|
||||||
|
media_type: string;
|
||||||
|
originally_available_at: string;
|
||||||
|
parent_media_index: number;
|
||||||
|
parent_rating_key: number;
|
||||||
|
parent_title: string;
|
||||||
|
paused_counter: number;
|
||||||
|
percent_complete: number;
|
||||||
|
platform: string;
|
||||||
|
product: string;
|
||||||
|
player: string;
|
||||||
|
rating_key: number;
|
||||||
|
reference_id?: number;
|
||||||
|
row_id?: number;
|
||||||
|
session_key?: string;
|
||||||
|
started: number;
|
||||||
|
state?: string;
|
||||||
|
stopped: number;
|
||||||
|
thumb: string;
|
||||||
|
title: string;
|
||||||
|
transcode_decision: string;
|
||||||
|
user: string;
|
||||||
|
user_id: number;
|
||||||
|
watched_status: number;
|
||||||
|
year: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TautulliHistoryResponse {
|
||||||
|
response: {
|
||||||
|
result: string;
|
||||||
|
message?: string;
|
||||||
|
data: {
|
||||||
|
draw: number;
|
||||||
|
recordsTotal: number;
|
||||||
|
recordsFiltered: number;
|
||||||
|
total_duration: string;
|
||||||
|
filter_duration: string;
|
||||||
|
data: TautulliHistoryRecord[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TautulliWatchStats {
|
||||||
|
query_days: number;
|
||||||
|
total_time: number;
|
||||||
|
total_plays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TautulliWatchStatsResponse {
|
||||||
|
response: {
|
||||||
|
result: string;
|
||||||
|
message?: string;
|
||||||
|
data: TautulliWatchStats[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TautulliWatchUser {
|
||||||
|
friendly_name: string;
|
||||||
|
user_id: number;
|
||||||
|
user_thumb: string;
|
||||||
|
username: string;
|
||||||
|
total_plays: number;
|
||||||
|
total_time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TautulliWatchUsersResponse {
|
||||||
|
response: {
|
||||||
|
result: string;
|
||||||
|
message?: string;
|
||||||
|
data: TautulliWatchUser[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TautulliInfo {
|
||||||
|
tautulli_install_type: string;
|
||||||
|
tautulli_version: string;
|
||||||
|
tautulli_branch: string;
|
||||||
|
tautulli_commit: string;
|
||||||
|
tautulli_platform: string;
|
||||||
|
tautulli_platform_release: string;
|
||||||
|
tautulli_platform_version: string;
|
||||||
|
tautulli_platform_linux_distro: string;
|
||||||
|
tautulli_platform_device_name: string;
|
||||||
|
tautulli_python_version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TautulliInfoResponse {
|
||||||
|
response: {
|
||||||
|
result: string;
|
||||||
|
message?: string;
|
||||||
|
data: TautulliInfo;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class TautulliAPI {
|
||||||
|
private axios: AxiosInstance;
|
||||||
|
|
||||||
|
constructor(settings: TautulliSettings) {
|
||||||
|
this.axios = axios.create({
|
||||||
|
baseURL: `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${
|
||||||
|
settings.port
|
||||||
|
}${settings.urlBase ?? ''}`,
|
||||||
|
params: { apikey: settings.apiKey },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getInfo(): Promise<TautulliInfo> {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
await this.axios.get<TautulliInfoResponse>('/api/v2', {
|
||||||
|
params: { cmd: 'get_tautulli_info' },
|
||||||
|
})
|
||||||
|
).data.response.data;
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Something went wrong fetching Tautulli server info', {
|
||||||
|
label: 'Tautulli API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
throw new Error(
|
||||||
|
`[Tautulli] Failed to fetch Tautulli server info: ${e.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getMediaWatchStats(
|
||||||
|
ratingKey: string
|
||||||
|
): Promise<TautulliWatchStats[]> {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', {
|
||||||
|
params: {
|
||||||
|
cmd: 'get_item_watch_time_stats',
|
||||||
|
rating_key: ratingKey,
|
||||||
|
grouping: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).data.response.data;
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
'Something went wrong fetching media watch stats from Tautulli',
|
||||||
|
{
|
||||||
|
label: 'Tautulli API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
ratingKey,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`[Tautulli] Failed to fetch media watch stats: ${e.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getMediaWatchUsers(
|
||||||
|
ratingKey: string
|
||||||
|
): Promise<TautulliWatchUser[]> {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
await this.axios.get<TautulliWatchUsersResponse>('/api/v2', {
|
||||||
|
params: {
|
||||||
|
cmd: 'get_item_user_stats',
|
||||||
|
rating_key: ratingKey,
|
||||||
|
grouping: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).data.response.data;
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
'Something went wrong fetching media watch users from Tautulli',
|
||||||
|
{
|
||||||
|
label: 'Tautulli API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
ratingKey,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`[Tautulli] Failed to fetch media watch users: ${e.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getUserWatchStats(user: User): Promise<TautulliWatchStats> {
|
||||||
|
try {
|
||||||
|
if (!user.plexId) {
|
||||||
|
throw new Error('User does not have an associated Plex ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', {
|
||||||
|
params: {
|
||||||
|
cmd: 'get_user_watch_time_stats',
|
||||||
|
user_id: user.plexId,
|
||||||
|
query_days: 0,
|
||||||
|
grouping: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).data.response.data[0];
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
'Something went wrong fetching user watch stats from Tautulli',
|
||||||
|
{
|
||||||
|
label: 'Tautulli API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
user: user.displayName,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`[Tautulli] Failed to fetch user watch stats: ${e.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getUserWatchHistory(
|
||||||
|
user: User
|
||||||
|
): Promise<TautulliHistoryRecord[]> {
|
||||||
|
let results: TautulliHistoryRecord[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!user.plexId) {
|
||||||
|
throw new Error('User does not have an associated Plex ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
const take = 100;
|
||||||
|
let start = 0;
|
||||||
|
|
||||||
|
while (results.length < 20) {
|
||||||
|
const tautulliData = (
|
||||||
|
await this.axios.get<TautulliHistoryResponse>('/api/v2', {
|
||||||
|
params: {
|
||||||
|
cmd: 'get_history',
|
||||||
|
grouping: 1,
|
||||||
|
order_column: 'date',
|
||||||
|
order_dir: 'desc',
|
||||||
|
user_id: user.plexId,
|
||||||
|
media_type: 'movie,episode',
|
||||||
|
length: take,
|
||||||
|
start,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).data.response.data.data;
|
||||||
|
|
||||||
|
if (!tautulliData.length) {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
results = uniqWith(results.concat(tautulliData), (recordA, recordB) =>
|
||||||
|
recordA.grandparent_rating_key && recordB.grandparent_rating_key
|
||||||
|
? recordA.grandparent_rating_key === recordB.grandparent_rating_key
|
||||||
|
: recordA.parent_rating_key && recordB.parent_rating_key
|
||||||
|
? recordA.parent_rating_key === recordB.parent_rating_key
|
||||||
|
: recordA.rating_key === recordB.rating_key
|
||||||
|
);
|
||||||
|
|
||||||
|
start += take;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.slice(0, 20);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
'Something went wrong fetching user watch history from Tautulli',
|
||||||
|
{
|
||||||
|
label: 'Tautulli API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
user: user.displayName,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`[Tautulli] Failed to fetch user watch history: ${e.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TautulliAPI;
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
TmdbMovieDetails,
|
TmdbMovieDetails,
|
||||||
TmdbNetwork,
|
TmdbNetwork,
|
||||||
TmdbPersonCombinedCredits,
|
TmdbPersonCombinedCredits,
|
||||||
TmdbPersonDetail,
|
TmdbPersonDetails,
|
||||||
TmdbProductionCompany,
|
TmdbProductionCompany,
|
||||||
TmdbRegion,
|
TmdbRegion,
|
||||||
TmdbSearchMovieResponse,
|
TmdbSearchMovieResponse,
|
||||||
@@ -28,6 +28,10 @@ interface SearchOptions {
|
|||||||
language?: string;
|
language?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SingleSearchOptions extends SearchOptions {
|
||||||
|
year?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface DiscoverMovieOptions {
|
interface DiscoverMovieOptions {
|
||||||
page?: number;
|
page?: number;
|
||||||
includeAdult?: boolean;
|
includeAdult?: boolean;
|
||||||
@@ -116,15 +120,67 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public searchMovies = async ({
|
||||||
|
query,
|
||||||
|
page = 1,
|
||||||
|
includeAdult = false,
|
||||||
|
language = 'en',
|
||||||
|
year,
|
||||||
|
}: SingleSearchOptions): Promise<TmdbSearchMovieResponse> => {
|
||||||
|
try {
|
||||||
|
const data = await this.get<TmdbSearchMovieResponse>('/search/movie', {
|
||||||
|
params: { query, page, include_adult: includeAdult, language, year },
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
page: 1,
|
||||||
|
results: [],
|
||||||
|
total_pages: 1,
|
||||||
|
total_results: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public searchTvShows = async ({
|
||||||
|
query,
|
||||||
|
page = 1,
|
||||||
|
includeAdult = false,
|
||||||
|
language = 'en',
|
||||||
|
year,
|
||||||
|
}: SingleSearchOptions): Promise<TmdbSearchTvResponse> => {
|
||||||
|
try {
|
||||||
|
const data = await this.get<TmdbSearchTvResponse>('/search/tv', {
|
||||||
|
params: {
|
||||||
|
query,
|
||||||
|
page,
|
||||||
|
include_adult: includeAdult,
|
||||||
|
language,
|
||||||
|
first_air_date_year: year,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
page: 1,
|
||||||
|
results: [],
|
||||||
|
total_pages: 1,
|
||||||
|
total_results: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
public getPerson = async ({
|
public getPerson = async ({
|
||||||
personId,
|
personId,
|
||||||
language = 'en',
|
language = 'en',
|
||||||
}: {
|
}: {
|
||||||
personId: number;
|
personId: number;
|
||||||
language?: string;
|
language?: string;
|
||||||
}): Promise<TmdbPersonDetail> => {
|
}): Promise<TmdbPersonDetails> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<TmdbPersonDetail>(`/person/${personId}`, {
|
const data = await this.get<TmdbPersonDetails>(`/person/${personId}`, {
|
||||||
params: { language },
|
params: { language },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -561,13 +617,13 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMovieByImdbId({
|
public async getMediaByImdbId({
|
||||||
imdbId,
|
imdbId,
|
||||||
language = 'en',
|
language = 'en',
|
||||||
}: {
|
}: {
|
||||||
imdbId: string;
|
imdbId: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
}): Promise<TmdbMovieDetails> {
|
}): Promise<TmdbMovieDetails | TmdbTvDetails> {
|
||||||
try {
|
try {
|
||||||
const extResponse = await this.getByExternalId({
|
const extResponse = await this.getByExternalId({
|
||||||
externalId: imdbId,
|
externalId: imdbId,
|
||||||
@@ -583,12 +639,19 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
return movie;
|
return movie;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
if (extResponse.tv_results[0]) {
|
||||||
'[TMDb] Failed to find a title with the provided IMDB id'
|
const tvshow = await this.getTvShow({
|
||||||
);
|
tvId: extResponse.tv_results[0].id,
|
||||||
|
language,
|
||||||
|
});
|
||||||
|
|
||||||
|
return tvshow;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`No movie or show returned from API for ID ${imdbId}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[TMDb] Failed to get movie by external imdb ID: ${e.message}`
|
`[TMDb] Failed to find media using external IMDb ID: ${e.message}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export interface TmdbUpcomingMoviesResponse extends TmdbPaginatedResponse {
|
|||||||
export interface TmdbExternalIdResponse {
|
export interface TmdbExternalIdResponse {
|
||||||
movie_results: TmdbMovieResult[];
|
movie_results: TmdbMovieResult[];
|
||||||
tv_results: TmdbTvResult[];
|
tv_results: TmdbTvResult[];
|
||||||
|
person_results: TmdbPersonResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TmdbCreditCast {
|
export interface TmdbCreditCast {
|
||||||
@@ -251,6 +252,10 @@ export interface TmdbTvDetails {
|
|||||||
name: string;
|
name: string;
|
||||||
origin_country: string;
|
origin_country: string;
|
||||||
}[];
|
}[];
|
||||||
|
production_countries: {
|
||||||
|
iso_3166_1: string;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
spoken_languages: {
|
spoken_languages: {
|
||||||
english_name: string;
|
english_name: string;
|
||||||
iso_639_1: string;
|
iso_639_1: string;
|
||||||
@@ -311,7 +316,7 @@ export interface TmdbKeyword {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TmdbPersonDetail {
|
export interface TmdbPersonDetails {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
birthday: string;
|
birthday: string;
|
||||||
@@ -320,7 +325,7 @@ export interface TmdbPersonDetail {
|
|||||||
also_known_as?: string[];
|
also_known_as?: string[];
|
||||||
gender: number;
|
gender: number;
|
||||||
biography: string;
|
biography: string;
|
||||||
popularity: string;
|
popularity: number;
|
||||||
place_of_birth?: string;
|
place_of_birth?: string;
|
||||||
profile_path?: string;
|
profile_path?: string;
|
||||||
adult: boolean;
|
adult: boolean;
|
||||||
|
|||||||
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;
|
||||||
@@ -17,6 +17,7 @@ import { MediaServerType } from '../constants/server';
|
|||||||
import downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
|
import downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
|
||||||
import { getSettings } from '../lib/settings';
|
import { getSettings } from '../lib/settings';
|
||||||
import logger from '../logger';
|
import logger from '../logger';
|
||||||
|
import Issue from './Issue';
|
||||||
import { MediaRequest } from './MediaRequest';
|
import { MediaRequest } from './MediaRequest';
|
||||||
import Season from './Season';
|
import Season from './Season';
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ class Media {
|
|||||||
try {
|
try {
|
||||||
const media = await mediaRepository.findOne({
|
const media = await mediaRepository.findOne({
|
||||||
where: { tmdbId: id, mediaType },
|
where: { tmdbId: id, mediaType },
|
||||||
relations: ['requests'],
|
relations: ['requests', 'issues'],
|
||||||
});
|
});
|
||||||
|
|
||||||
return media;
|
return media;
|
||||||
@@ -98,6 +99,9 @@ class Media {
|
|||||||
})
|
})
|
||||||
public seasons: Season[];
|
public seasons: Season[];
|
||||||
|
|
||||||
|
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
|
||||||
|
public issues: Issue[];
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@@ -148,27 +152,55 @@ class Media {
|
|||||||
public mediaUrl?: string;
|
public mediaUrl?: string;
|
||||||
public mediaUrl4k?: string;
|
public mediaUrl4k?: string;
|
||||||
|
|
||||||
|
public tautulliUrl?: string;
|
||||||
|
public tautulliUrl4k?: string;
|
||||||
|
|
||||||
constructor(init?: Partial<Media>) {
|
constructor(init?: Partial<Media>) {
|
||||||
Object.assign(this, init);
|
Object.assign(this, init);
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterLoad()
|
@AfterLoad()
|
||||||
public setMediaUrls(): void {
|
public setPlexUrls(): void {
|
||||||
const settings = getSettings();
|
const { machineId, webAppUrl } = getSettings().plex;
|
||||||
if (settings.main.mediaServerType == MediaServerType.PLEX) {
|
const { externalUrl: tautulliUrl } = getSettings().tautulli;
|
||||||
|
|
||||||
|
if (getSettings().main.mediaServerType == MediaServerType.PLEX) {
|
||||||
if (this.ratingKey) {
|
if (this.ratingKey) {
|
||||||
this.mediaUrl = `https://app.plex.tv/desktop#!/server/${settings.plex.machineId}/details?key=%2Flibrary%2Fmetadata%2F${this.ratingKey}`;
|
this.mediaUrl = `${
|
||||||
|
webAppUrl ? webAppUrl : 'https://app.plex.tv/desktop'
|
||||||
|
}#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${
|
||||||
|
this.ratingKey
|
||||||
|
}`;
|
||||||
|
|
||||||
|
if (tautulliUrl) {
|
||||||
|
this.tautulliUrl = `${tautulliUrl}/info?rating_key=${this.ratingKey}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.ratingKey4k) {
|
if (this.ratingKey4k) {
|
||||||
this.mediaUrl4k = `https://app.plex.tv/desktop#!/server/${settings.plex.machineId}/details?key=%2Flibrary%2Fmetadata%2F${this.ratingKey4k}`;
|
this.mediaUrl4k = `${
|
||||||
|
webAppUrl ? webAppUrl : 'https://app.plex.tv/desktop'
|
||||||
|
}#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${
|
||||||
|
this.ratingKey4k
|
||||||
|
}`;
|
||||||
|
|
||||||
|
if (tautulliUrl) {
|
||||||
|
this.tautulliUrl4k = `${tautulliUrl}/info?rating_key=${this.ratingKey4k}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const pageName = process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
|
const pageName =
|
||||||
|
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
|
||||||
|
const { serverId, hostname, externalHostname } = getSettings().jellyfin;
|
||||||
|
const jellyfinHost =
|
||||||
|
externalHostname && externalHostname.length > 0
|
||||||
|
? externalHostname
|
||||||
|
: hostname;
|
||||||
if (this.jellyfinMediaId) {
|
if (this.jellyfinMediaId) {
|
||||||
this.mediaUrl = `${settings.jellyfin.hostname}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${settings.jellyfin.serverId}`;
|
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
|
||||||
}
|
}
|
||||||
if (this.jellyfinMediaId4k) {
|
if (this.jellyfinMediaId4k) {
|
||||||
this.mediaUrl4k = `${settings.jellyfin.hostname}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId4k}&context=home&serverId=${settings.jellyfin.serverId}`;
|
this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId4k}&context=home&serverId=${serverId}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ import {
|
|||||||
RelationCount,
|
RelationCount,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import RadarrAPI from '../api/servarr/radarr';
|
import RadarrAPI, { RadarrMovieOptions } from '../api/servarr/radarr';
|
||||||
import SonarrAPI, { SonarrSeries } from '../api/servarr/sonarr';
|
import SonarrAPI, {
|
||||||
|
AddSeriesOptions,
|
||||||
|
SonarrSeries,
|
||||||
|
} from '../api/servarr/sonarr';
|
||||||
import TheMovieDb from '../api/themoviedb';
|
import TheMovieDb from '../api/themoviedb';
|
||||||
import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants';
|
import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants';
|
||||||
import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
|
import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
|
||||||
@@ -135,51 +138,15 @@ export class MediaRequest {
|
|||||||
where: { id: this.media.id },
|
where: { id: this.media.id },
|
||||||
});
|
});
|
||||||
if (!media) {
|
if (!media) {
|
||||||
logger.error('No parent media!', { label: 'Media Request' });
|
logger.error('Media data not found', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tmdb = new TheMovieDb();
|
|
||||||
if (this.type === MediaType.MOVIE) {
|
|
||||||
const movie = await tmdb.getMovie({ movieId: media.tmdbId });
|
|
||||||
notificationManager.sendNotification(Notification.MEDIA_PENDING, {
|
|
||||||
subject: `${movie.title}${
|
|
||||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
|
||||||
}`,
|
|
||||||
message: truncate(movie.overview, {
|
|
||||||
length: 500,
|
|
||||||
separator: /\s/,
|
|
||||||
omission: '…',
|
|
||||||
}),
|
|
||||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
|
||||||
media,
|
|
||||||
request: this,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.type === MediaType.TV) {
|
this.sendNotification(media, Notification.MEDIA_PENDING);
|
||||||
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
|
|
||||||
notificationManager.sendNotification(Notification.MEDIA_PENDING, {
|
|
||||||
subject: `${tv.name}${
|
|
||||||
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
|
||||||
}`,
|
|
||||||
message: truncate(tv.overview, {
|
|
||||||
length: 500,
|
|
||||||
separator: /\s/,
|
|
||||||
omission: '…',
|
|
||||||
}),
|
|
||||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
|
||||||
media,
|
|
||||||
extra: [
|
|
||||||
{
|
|
||||||
name: 'Seasons',
|
|
||||||
value: this.seasons
|
|
||||||
.map((season) => season.seasonNumber)
|
|
||||||
.join(', '),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
request: this,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,74 +167,30 @@ export class MediaRequest {
|
|||||||
where: { id: this.media.id },
|
where: { id: this.media.id },
|
||||||
});
|
});
|
||||||
if (!media) {
|
if (!media) {
|
||||||
logger.error('No parent media!', { label: 'Media Request' });
|
logger.error('Media data not found', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE) {
|
if (media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'Media became available before request was approved. Approval notification will be skipped.',
|
'Media became available before request was approved. Skipping approval notification',
|
||||||
{ label: 'Media Request' }
|
{ label: 'Media Request', requestId: this.id, mediaId: this.media.id }
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tmdb = new TheMovieDb();
|
this.sendNotification(
|
||||||
if (this.media.mediaType === MediaType.MOVIE) {
|
media,
|
||||||
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
|
this.status === MediaRequestStatus.APPROVED
|
||||||
notificationManager.sendNotification(
|
? autoApproved
|
||||||
this.status === MediaRequestStatus.APPROVED
|
? Notification.MEDIA_AUTO_APPROVED
|
||||||
? autoApproved
|
: Notification.MEDIA_APPROVED
|
||||||
? Notification.MEDIA_AUTO_APPROVED
|
: Notification.MEDIA_DECLINED
|
||||||
: Notification.MEDIA_APPROVED
|
);
|
||||||
: Notification.MEDIA_DECLINED,
|
|
||||||
{
|
|
||||||
subject: `${movie.title}${
|
|
||||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
|
||||||
}`,
|
|
||||||
message: truncate(movie.overview, {
|
|
||||||
length: 500,
|
|
||||||
separator: /\s/,
|
|
||||||
omission: '…',
|
|
||||||
}),
|
|
||||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
|
||||||
notifyUser: autoApproved ? undefined : this.requestedBy,
|
|
||||||
media,
|
|
||||||
request: this,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else if (this.media.mediaType === MediaType.TV) {
|
|
||||||
const tv = await tmdb.getTvShow({ tvId: this.media.tmdbId });
|
|
||||||
notificationManager.sendNotification(
|
|
||||||
this.status === MediaRequestStatus.APPROVED
|
|
||||||
? autoApproved
|
|
||||||
? Notification.MEDIA_AUTO_APPROVED
|
|
||||||
: Notification.MEDIA_APPROVED
|
|
||||||
: Notification.MEDIA_DECLINED,
|
|
||||||
{
|
|
||||||
subject: `${tv.name}${
|
|
||||||
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
|
||||||
}`,
|
|
||||||
message: truncate(tv.overview, {
|
|
||||||
length: 500,
|
|
||||||
separator: /\s/,
|
|
||||||
omission: '…',
|
|
||||||
}),
|
|
||||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
|
||||||
notifyUser: autoApproved ? undefined : this.requestedBy,
|
|
||||||
media,
|
|
||||||
extra: [
|
|
||||||
{
|
|
||||||
name: 'Seasons',
|
|
||||||
value: this.seasons
|
|
||||||
.map((season) => season.seasonNumber)
|
|
||||||
.join(', '),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
request: this,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +210,11 @@ export class MediaRequest {
|
|||||||
relations: ['requests'],
|
relations: ['requests'],
|
||||||
});
|
});
|
||||||
if (!media) {
|
if (!media) {
|
||||||
logger.error('No parent media!', { label: 'Media Request' });
|
logger.error('Media data not found', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const seasonRequestRepository = getRepository(SeasonRequest);
|
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||||
@@ -375,8 +302,12 @@ export class MediaRequest {
|
|||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
if (settings.radarr.length === 0 && !settings.radarr[0]) {
|
if (settings.radarr.length === 0 && !settings.radarr[0]) {
|
||||||
logger.info(
|
logger.info(
|
||||||
'Skipped Radarr request as there is no Radarr server configured',
|
'No Radarr server configured, skipping request processing',
|
||||||
{ label: 'Media Request' }
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -395,18 +326,26 @@ export class MediaRequest {
|
|||||||
);
|
);
|
||||||
logger.info(
|
logger.info(
|
||||||
`Request has an override server: ${radarrSettings?.name}`,
|
`Request has an override server: ${radarrSettings?.name}`,
|
||||||
{ label: 'Media Request' }
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!radarrSettings) {
|
if (!radarrSettings) {
|
||||||
logger.info(
|
logger.warn(
|
||||||
`There is no default ${
|
`There is no default ${
|
||||||
this.is4k ? '4K ' : ''
|
this.is4k ? '4K ' : ''
|
||||||
}Radarr server configured. Did you set any of your ${
|
}Radarr server configured. Did you set any of your ${
|
||||||
this.is4k ? '4K ' : ''
|
this.is4k ? '4K ' : ''
|
||||||
}Radarr servers as default?`,
|
}Radarr servers as default?`,
|
||||||
{ label: 'Media Request' }
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -423,6 +362,8 @@ export class MediaRequest {
|
|||||||
rootFolder = this.rootFolder;
|
rootFolder = this.rootFolder;
|
||||||
logger.info(`Request has an override root folder: ${rootFolder}`, {
|
logger.info(`Request has an override root folder: ${rootFolder}`, {
|
||||||
label: 'Media Request',
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,15 +372,22 @@ export class MediaRequest {
|
|||||||
this.profileId !== radarrSettings.activeProfileId
|
this.profileId !== radarrSettings.activeProfileId
|
||||||
) {
|
) {
|
||||||
qualityProfile = this.profileId;
|
qualityProfile = this.profileId;
|
||||||
logger.info(`Request has an override profile id: ${qualityProfile}`, {
|
logger.info(
|
||||||
label: 'Media Request',
|
`Request has an override quality profile ID: ${qualityProfile}`,
|
||||||
});
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.tags && !isEqual(this.tags, radarrSettings.tags)) {
|
if (this.tags && !isEqual(this.tags, radarrSettings.tags)) {
|
||||||
tags = this.tags;
|
tags = this.tags;
|
||||||
logger.info(`Request has override tags`, {
|
logger.info(`Request has override tags`, {
|
||||||
label: 'Media Request',
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
tagIds: tags,
|
tagIds: tags,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -456,7 +404,11 @@ export class MediaRequest {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!media) {
|
if (!media) {
|
||||||
logger.error('Media not present');
|
logger.error('Media data not found', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,20 +418,22 @@ export class MediaRequest {
|
|||||||
throw new Error('Media already available');
|
throw new Error('Media already available');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const radarrMovieOptions: RadarrMovieOptions = {
|
||||||
|
profileId: qualityProfile,
|
||||||
|
qualityProfileId: qualityProfile,
|
||||||
|
rootFolderPath: rootFolder,
|
||||||
|
minimumAvailability: radarrSettings.minimumAvailability,
|
||||||
|
title: movie.title,
|
||||||
|
tmdbId: movie.id,
|
||||||
|
year: Number(movie.release_date.slice(0, 4)),
|
||||||
|
monitored: true,
|
||||||
|
tags,
|
||||||
|
searchNow: !radarrSettings.preventSearch,
|
||||||
|
};
|
||||||
|
|
||||||
// Run this asynchronously so we don't wait for it on the UI side
|
// Run this asynchronously so we don't wait for it on the UI side
|
||||||
radarr
|
radarr
|
||||||
.addMovie({
|
.addMovie(radarrMovieOptions)
|
||||||
profileId: qualityProfile,
|
|
||||||
qualityProfileId: qualityProfile,
|
|
||||||
rootFolderPath: rootFolder,
|
|
||||||
minimumAvailability: radarrSettings.minimumAvailability,
|
|
||||||
title: movie.title,
|
|
||||||
tmdbId: movie.id,
|
|
||||||
year: Number(movie.release_date.slice(0, 4)),
|
|
||||||
monitored: true,
|
|
||||||
tags,
|
|
||||||
searchNow: !radarrSettings.preventSearch,
|
|
||||||
})
|
|
||||||
.then(async (radarrMovie) => {
|
.then(async (radarrMovie) => {
|
||||||
// We grab media again here to make sure we have the latest version of it
|
// We grab media again here to make sure we have the latest version of it
|
||||||
const media = await mediaRepository.findOne({
|
const media = await mediaRepository.findOne({
|
||||||
@@ -487,7 +441,7 @@ export class MediaRequest {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!media) {
|
if (!media) {
|
||||||
throw new Error('Media data is missing');
|
throw new Error('Media data not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||||
@@ -501,34 +455,30 @@ export class MediaRequest {
|
|||||||
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||||
await mediaRepository.save(media);
|
await mediaRepository.save(media);
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'Newly added movie request failed to add to Radarr, marking as unknown',
|
'Something went wrong sending movie request to Radarr, marking status as UNKNOWN',
|
||||||
{
|
{
|
||||||
label: 'Media Request',
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
radarrMovieOptions,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
|
this.sendNotification(media, Notification.MEDIA_FAILED);
|
||||||
subject: `${movie.title}${
|
|
||||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
|
||||||
}`,
|
|
||||||
message: truncate(movie.overview, {
|
|
||||||
length: 500,
|
|
||||||
separator: /\s/,
|
|
||||||
omission: '…',
|
|
||||||
}),
|
|
||||||
media,
|
|
||||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
|
||||||
request: this,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
logger.info('Sent request to Radarr', { label: 'Media Request' });
|
logger.info('Sent request to Radarr', {
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = `Request failed to send to Radarr: ${e.message}`;
|
|
||||||
logger.error('Request failed to send to Radarr', {
|
|
||||||
label: 'Media Request',
|
label: 'Media Request',
|
||||||
errorMessage,
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
});
|
});
|
||||||
throw new Error(errorMessage);
|
} catch (e) {
|
||||||
|
logger.error('Something went wrong sending request to Radarr', {
|
||||||
|
label: 'Media Request',
|
||||||
|
errorMessage: e.message,
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
});
|
||||||
|
throw new Error(e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -542,9 +492,13 @@ export class MediaRequest {
|
|||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
if (settings.sonarr.length === 0 && !settings.sonarr[0]) {
|
if (settings.sonarr.length === 0 && !settings.sonarr[0]) {
|
||||||
logger.info(
|
logger.warn(
|
||||||
'Skipped Sonarr request as there is no Sonarr server configured',
|
'No Sonarr server configured, skipping request processing',
|
||||||
{ label: 'Media Request' }
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -563,18 +517,26 @@ export class MediaRequest {
|
|||||||
);
|
);
|
||||||
logger.info(
|
logger.info(
|
||||||
`Request has an override server: ${sonarrSettings?.name}`,
|
`Request has an override server: ${sonarrSettings?.name}`,
|
||||||
{ label: 'Media Request' }
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sonarrSettings) {
|
if (!sonarrSettings) {
|
||||||
logger.info(
|
logger.warn(
|
||||||
`There is no default ${
|
`There is no default ${
|
||||||
this.is4k ? '4K ' : ''
|
this.is4k ? '4K ' : ''
|
||||||
}Sonarr server configured. Did you set any of your ${
|
}Sonarr server configured. Did you set any of your ${
|
||||||
this.is4k ? '4K ' : ''
|
this.is4k ? '4K ' : ''
|
||||||
}Sonarr servers as default?`,
|
}Sonarr servers as default?`,
|
||||||
{ label: 'Media Request' }
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -585,7 +547,7 @@ export class MediaRequest {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!media) {
|
if (!media) {
|
||||||
throw new Error('Media data is missing');
|
throw new Error('Media data not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -606,7 +568,7 @@ export class MediaRequest {
|
|||||||
const requestRepository = getRepository(MediaRequest);
|
const requestRepository = getRepository(MediaRequest);
|
||||||
await mediaRepository.remove(media);
|
await mediaRepository.remove(media);
|
||||||
await requestRepository.remove(this);
|
await requestRepository.remove(this);
|
||||||
throw new Error('Series was missing tvdb id');
|
throw new Error('TVDB ID not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
let seriesType: SonarrSeries['seriesType'] = 'standard';
|
let seriesType: SonarrSeries['seriesType'] = 'standard';
|
||||||
@@ -628,12 +590,10 @@ export class MediaRequest {
|
|||||||
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
|
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
|
||||||
? sonarrSettings.activeAnimeProfileId
|
? sonarrSettings.activeAnimeProfileId
|
||||||
: sonarrSettings.activeProfileId;
|
: sonarrSettings.activeProfileId;
|
||||||
|
|
||||||
let languageProfile =
|
let languageProfile =
|
||||||
seriesType === 'anime' && sonarrSettings.activeAnimeLanguageProfileId
|
seriesType === 'anime' && sonarrSettings.activeAnimeLanguageProfileId
|
||||||
? sonarrSettings.activeAnimeLanguageProfileId
|
? sonarrSettings.activeAnimeLanguageProfileId
|
||||||
: sonarrSettings.activeLanguageProfileId;
|
: sonarrSettings.activeLanguageProfileId;
|
||||||
|
|
||||||
let tags =
|
let tags =
|
||||||
seriesType === 'anime'
|
seriesType === 'anime'
|
||||||
? sonarrSettings.animeTags
|
? sonarrSettings.animeTags
|
||||||
@@ -647,14 +607,21 @@ export class MediaRequest {
|
|||||||
rootFolder = this.rootFolder;
|
rootFolder = this.rootFolder;
|
||||||
logger.info(`Request has an override root folder: ${rootFolder}`, {
|
logger.info(`Request has an override root folder: ${rootFolder}`, {
|
||||||
label: 'Media Request',
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.profileId && this.profileId !== qualityProfile) {
|
if (this.profileId && this.profileId !== qualityProfile) {
|
||||||
qualityProfile = this.profileId;
|
qualityProfile = this.profileId;
|
||||||
logger.info(`Request has an override profile ID: ${qualityProfile}`, {
|
logger.info(
|
||||||
label: 'Media Request',
|
`Request has an override quality profile ID: ${qualityProfile}`,
|
||||||
});
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -663,9 +630,11 @@ export class MediaRequest {
|
|||||||
) {
|
) {
|
||||||
languageProfile = this.languageProfileId;
|
languageProfile = this.languageProfileId;
|
||||||
logger.info(
|
logger.info(
|
||||||
`Request has an override Language Profile: ${languageProfile}`,
|
`Request has an override language profile ID: ${languageProfile}`,
|
||||||
{
|
{
|
||||||
label: 'Media Request',
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -674,25 +643,29 @@ export class MediaRequest {
|
|||||||
tags = this.tags;
|
tags = this.tags;
|
||||||
logger.info(`Request has override tags`, {
|
logger.info(`Request has override tags`, {
|
||||||
label: 'Media Request',
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
tagIds: tags,
|
tagIds: tags,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sonarrSeriesOptions: AddSeriesOptions = {
|
||||||
|
profileId: qualityProfile,
|
||||||
|
languageProfileId: languageProfile,
|
||||||
|
rootFolderPath: rootFolder,
|
||||||
|
title: series.name,
|
||||||
|
tvdbid: tvdbId,
|
||||||
|
seasons: this.seasons.map((season) => season.seasonNumber),
|
||||||
|
seasonFolder: sonarrSettings.enableSeasonFolders,
|
||||||
|
seriesType,
|
||||||
|
tags,
|
||||||
|
monitored: true,
|
||||||
|
searchNow: !sonarrSettings.preventSearch,
|
||||||
|
};
|
||||||
|
|
||||||
// Run this asynchronously so we don't wait for it on the UI side
|
// Run this asynchronously so we don't wait for it on the UI side
|
||||||
sonarr
|
sonarr
|
||||||
.addSeries({
|
.addSeries(sonarrSeriesOptions)
|
||||||
profileId: qualityProfile,
|
|
||||||
languageProfileId: languageProfile,
|
|
||||||
rootFolderPath: rootFolder,
|
|
||||||
title: series.name,
|
|
||||||
tvdbid: tvdbId,
|
|
||||||
seasons: this.seasons.map((season) => season.seasonNumber),
|
|
||||||
seasonFolder: sonarrSettings.enableSeasonFolders,
|
|
||||||
seriesType,
|
|
||||||
tags,
|
|
||||||
monitored: true,
|
|
||||||
searchNow: !sonarrSettings.preventSearch,
|
|
||||||
})
|
|
||||||
.then(async (sonarrSeries) => {
|
.then(async (sonarrSeries) => {
|
||||||
// We grab media again here to make sure we have the latest version of it
|
// We grab media again here to make sure we have the latest version of it
|
||||||
const media = await mediaRepository.findOne({
|
const media = await mediaRepository.findOne({
|
||||||
@@ -701,7 +674,7 @@ export class MediaRequest {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!media) {
|
if (!media) {
|
||||||
throw new Error('Media data is missing');
|
throw new Error('Media data not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||||
@@ -715,45 +688,116 @@ export class MediaRequest {
|
|||||||
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||||
await mediaRepository.save(media);
|
await mediaRepository.save(media);
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'Newly added series request failed to add to Sonarr, marking as unknown',
|
'Something went wrong sending series request to Sonarr, marking status as UNKNOWN',
|
||||||
{
|
{
|
||||||
label: 'Media Request',
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
sonarrSeriesOptions,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
|
this.sendNotification(media, Notification.MEDIA_FAILED);
|
||||||
subject: `${series.name}${
|
|
||||||
series.first_air_date
|
|
||||||
? ` (${series.first_air_date.slice(0, 4)})`
|
|
||||||
: ''
|
|
||||||
}`,
|
|
||||||
message: truncate(series.overview, {
|
|
||||||
length: 500,
|
|
||||||
separator: /\s/,
|
|
||||||
omission: '…',
|
|
||||||
}),
|
|
||||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${series.poster_path}`,
|
|
||||||
media,
|
|
||||||
extra: [
|
|
||||||
{
|
|
||||||
name: 'Seasons',
|
|
||||||
value: this.seasons
|
|
||||||
.map((season) => season.seasonNumber)
|
|
||||||
.join(', '),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
request: this,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
logger.info('Sent request to Sonarr', { label: 'Media Request' });
|
logger.info('Sent request to Sonarr', {
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = `Request failed to send to Sonarr: ${e.message}`;
|
|
||||||
logger.error('Request failed to send to Sonarr', {
|
|
||||||
label: 'Media Request',
|
label: 'Media Request',
|
||||||
errorMessage,
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
});
|
});
|
||||||
throw new Error(errorMessage);
|
} catch (e) {
|
||||||
|
logger.error('Something went wrong sending request to Sonarr', {
|
||||||
|
label: 'Media Request',
|
||||||
|
errorMessage: e.message,
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
});
|
||||||
|
throw new Error(e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async sendNotification(media: Media, type: Notification) {
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series';
|
||||||
|
let event: string | undefined;
|
||||||
|
let notifyAdmin = true;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case Notification.MEDIA_APPROVED:
|
||||||
|
event = `${this.is4k ? '4K ' : ''}${mediaType} Request Approved`;
|
||||||
|
notifyAdmin = false;
|
||||||
|
break;
|
||||||
|
case Notification.MEDIA_DECLINED:
|
||||||
|
event = `${this.is4k ? '4K ' : ''}${mediaType} Request Declined`;
|
||||||
|
notifyAdmin = false;
|
||||||
|
break;
|
||||||
|
case Notification.MEDIA_PENDING:
|
||||||
|
event = `New ${this.is4k ? '4K ' : ''}${mediaType} Request`;
|
||||||
|
break;
|
||||||
|
case Notification.MEDIA_AUTO_APPROVED:
|
||||||
|
event = `${
|
||||||
|
this.is4k ? '4K ' : ''
|
||||||
|
}${mediaType} Request Automatically Approved`;
|
||||||
|
break;
|
||||||
|
case Notification.MEDIA_FAILED:
|
||||||
|
event = `${this.is4k ? '4K ' : ''}${mediaType} Request Failed`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.type === MediaType.MOVIE) {
|
||||||
|
const movie = await tmdb.getMovie({ movieId: media.tmdbId });
|
||||||
|
notificationManager.sendNotification(type, {
|
||||||
|
media,
|
||||||
|
request: this,
|
||||||
|
notifyAdmin,
|
||||||
|
notifyUser: notifyAdmin ? undefined : this.requestedBy,
|
||||||
|
event,
|
||||||
|
subject: `${movie.title}${
|
||||||
|
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||||
|
}`,
|
||||||
|
message: truncate(movie.overview, {
|
||||||
|
length: 500,
|
||||||
|
separator: /\s/,
|
||||||
|
omission: '…',
|
||||||
|
}),
|
||||||
|
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||||
|
});
|
||||||
|
} else if (this.type === MediaType.TV) {
|
||||||
|
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||||
|
notificationManager.sendNotification(type, {
|
||||||
|
media,
|
||||||
|
request: this,
|
||||||
|
notifyAdmin,
|
||||||
|
notifyUser: notifyAdmin ? undefined : this.requestedBy,
|
||||||
|
event,
|
||||||
|
subject: `${tv.name}${
|
||||||
|
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
||||||
|
}`,
|
||||||
|
message: truncate(tv.overview, {
|
||||||
|
length: 500,
|
||||||
|
separator: /\s/,
|
||||||
|
omission: '…',
|
||||||
|
}),
|
||||||
|
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||||
|
extra: [
|
||||||
|
{
|
||||||
|
name: 'Requested Seasons',
|
||||||
|
value: this.seasons
|
||||||
|
.map((season) => season.seasonNumber)
|
||||||
|
.join(', '),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Something went wrong sending media notification(s)', {
|
||||||
|
label: 'Notifications',
|
||||||
|
errorMessage: e.message,
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from '../lib/permissions';
|
} from '../lib/permissions';
|
||||||
import { getSettings } from '../lib/settings';
|
import { getSettings } from '../lib/settings';
|
||||||
import logger from '../logger';
|
import logger from '../logger';
|
||||||
|
import Issue from './Issue';
|
||||||
import { MediaRequest } from './MediaRequest';
|
import { MediaRequest } from './MediaRequest';
|
||||||
import SeasonRequest from './SeasonRequest';
|
import SeasonRequest from './SeasonRequest';
|
||||||
import { UserPushSubscription } from './UserPushSubscription';
|
import { UserPushSubscription } from './UserPushSubscription';
|
||||||
@@ -61,7 +62,7 @@ export class User {
|
|||||||
public plexUsername?: string;
|
public plexUsername?: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public jellyfinUsername: string;
|
public jellyfinUsername?: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public username?: string;
|
public username?: string;
|
||||||
@@ -127,6 +128,9 @@ export class User {
|
|||||||
@OneToMany(() => UserPushSubscription, (pushSub) => pushSub.user)
|
@OneToMany(() => UserPushSubscription, (pushSub) => pushSub.user)
|
||||||
public pushSubscriptions: UserPushSubscription[];
|
public pushSubscriptions: UserPushSubscription[];
|
||||||
|
|
||||||
|
@OneToMany(() => Issue, (issue) => issue.createdBy, { cascade: true })
|
||||||
|
public createdIssues: Issue[];
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@@ -190,6 +194,7 @@ export class User {
|
|||||||
password: password,
|
password: password,
|
||||||
applicationUrl,
|
applicationUrl,
|
||||||
applicationTitle,
|
applicationTitle,
|
||||||
|
recipientName: this.username,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -226,6 +231,8 @@ export class User {
|
|||||||
resetPasswordLink,
|
resetPasswordLink,
|
||||||
applicationUrl,
|
applicationUrl,
|
||||||
applicationTitle,
|
applicationTitle,
|
||||||
|
recipientName: this.displayName,
|
||||||
|
recipientEmail: this.email,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -239,8 +246,7 @@ export class User {
|
|||||||
@AfterLoad()
|
@AfterLoad()
|
||||||
public setDisplayName(): void {
|
public setDisplayName(): void {
|
||||||
this.displayName =
|
this.displayName =
|
||||||
this.username || this.plexUsername || this.jellyfinUsername;
|
this.username || this.plexUsername || this.jellyfinUsername || this.email;
|
||||||
this.displayName = this.username || this.plexUsername || this.email;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getQuota(): Promise<QuotaResponse> {
|
public async getQuota(): Promise<QuotaResponse> {
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ export class UserSettings {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public discordId?: string;
|
public discordId?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public pushbulletAccessToken?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public pushoverApplicationToken?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public pushoverUserKey?: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public telegramChatId?: string;
|
public telegramChatId?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { startJobs } from './job/schedule';
|
|||||||
import notificationManager from './lib/notifications';
|
import notificationManager from './lib/notifications';
|
||||||
import DiscordAgent from './lib/notifications/agents/discord';
|
import DiscordAgent from './lib/notifications/agents/discord';
|
||||||
import EmailAgent from './lib/notifications/agents/email';
|
import EmailAgent from './lib/notifications/agents/email';
|
||||||
|
import GotifyAgent from './lib/notifications/agents/gotify';
|
||||||
import LunaSeaAgent from './lib/notifications/agents/lunasea';
|
import LunaSeaAgent from './lib/notifications/agents/lunasea';
|
||||||
import PushbulletAgent from './lib/notifications/agents/pushbullet';
|
import PushbulletAgent from './lib/notifications/agents/pushbullet';
|
||||||
import PushoverAgent from './lib/notifications/agents/pushover';
|
import PushoverAgent from './lib/notifications/agents/pushover';
|
||||||
@@ -31,7 +32,7 @@ import { getAppVersion } from './utils/appVersion';
|
|||||||
|
|
||||||
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
|
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
|
||||||
|
|
||||||
logger.info(`Starting Jellyseerr version ${getAppVersion()}`);
|
logger.info(`Starting Overseerr version ${getAppVersion()}`);
|
||||||
const dev = process.env.NODE_ENV !== 'production';
|
const dev = process.env.NODE_ENV !== 'production';
|
||||||
const app = next({ dev });
|
const app = next({ dev });
|
||||||
const handle = app.getRequestHandler();
|
const handle = app.getRequestHandler();
|
||||||
@@ -63,11 +64,12 @@ app
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (admin) {
|
if (admin) {
|
||||||
const plexapi = new PlexAPI({ plexToken: admin.plexToken });
|
logger.info('Migrating Plex libraries to include media type', {
|
||||||
await plexapi.syncLibraries();
|
|
||||||
logger.info('Migrating libraries to include media type', {
|
|
||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const plexapi = new PlexAPI({ plexToken: admin.plexToken });
|
||||||
|
await plexapi.syncLibraries();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +77,7 @@ app
|
|||||||
notificationManager.registerAgents([
|
notificationManager.registerAgents([
|
||||||
new DiscordAgent(),
|
new DiscordAgent(),
|
||||||
new EmailAgent(),
|
new EmailAgent(),
|
||||||
|
new GotifyAgent(),
|
||||||
new LunaSeaAgent(),
|
new LunaSeaAgent(),
|
||||||
new PushbulletAgent(),
|
new PushbulletAgent(),
|
||||||
new PushoverAgent(),
|
new PushoverAgent(),
|
||||||
@@ -138,6 +141,9 @@ app
|
|||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
cookie: {
|
cookie: {
|
||||||
maxAge: 1000 * 60 * 60 * 24 * 30,
|
maxAge: 1000 * 60 * 60 * 24 * 30,
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: true,
|
||||||
|
secure: 'auto',
|
||||||
},
|
},
|
||||||
store: new TypeormStore({
|
store: new TypeormStore({
|
||||||
cleanupLimit: 2,
|
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[];
|
||||||
|
}
|
||||||
@@ -1,6 +1,22 @@
|
|||||||
import type Media from '../../entity/Media';
|
import type Media from '../../entity/Media';
|
||||||
|
import { User } from '../../entity/User';
|
||||||
import { PaginatedResponse } from './common';
|
import { PaginatedResponse } from './common';
|
||||||
|
|
||||||
export interface MediaResultsResponse extends PaginatedResponse {
|
export interface MediaResultsResponse extends PaginatedResponse {
|
||||||
results: Media[];
|
results: Media[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MediaWatchDataResponse {
|
||||||
|
data?: {
|
||||||
|
users: User[];
|
||||||
|
playCount: number;
|
||||||
|
playCount7Days: number;
|
||||||
|
playCount30Days: number;
|
||||||
|
};
|
||||||
|
data4k?: {
|
||||||
|
users: User[];
|
||||||
|
playCount: number;
|
||||||
|
playCount7Days: number;
|
||||||
|
playCount30Days: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { PaginatedResponse } from './common';
|
|||||||
export type LogMessage = {
|
export type LogMessage = {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
level: string;
|
level: string;
|
||||||
label: string;
|
label?: string;
|
||||||
message: string;
|
message: string;
|
||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
@@ -17,6 +17,7 @@ export interface SettingsAboutResponse {
|
|||||||
totalRequests: number;
|
totalRequests: number;
|
||||||
totalMediaItems: number;
|
totalMediaItems: number;
|
||||||
tz?: string;
|
tz?: string;
|
||||||
|
appDataPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicSettingsResponse {
|
export interface PublicSettingsResponse {
|
||||||
@@ -38,6 +39,7 @@ export interface PublicSettingsResponse {
|
|||||||
enablePushRegistration: boolean;
|
enablePushRegistration: boolean;
|
||||||
locale: string;
|
locale: string;
|
||||||
emailEnabled: boolean;
|
emailEnabled: boolean;
|
||||||
|
newPlexLogin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CacheItem {
|
export interface CacheItem {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Media from '../../entity/Media';
|
||||||
import { MediaRequest } from '../../entity/MediaRequest';
|
import { MediaRequest } from '../../entity/MediaRequest';
|
||||||
import type { User } from '../../entity/User';
|
import type { User } from '../../entity/User';
|
||||||
import { PaginatedResponse } from './common';
|
import { PaginatedResponse } from './common';
|
||||||
@@ -22,3 +23,7 @@ export interface QuotaResponse {
|
|||||||
movie: QuotaStatus;
|
movie: QuotaStatus;
|
||||||
tv: QuotaStatus;
|
tv: QuotaStatus;
|
||||||
}
|
}
|
||||||
|
export interface UserWatchDataResponse {
|
||||||
|
recentlyWatched: Media[];
|
||||||
|
playCount: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NotificationAgentKey } from '../../lib/settings';
|
|||||||
|
|
||||||
export interface UserSettingsGeneralResponse {
|
export interface UserSettingsGeneralResponse {
|
||||||
username?: string;
|
username?: string;
|
||||||
|
discordId?: string;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
region?: string;
|
region?: string;
|
||||||
originalLanguage?: string;
|
originalLanguage?: string;
|
||||||
@@ -22,6 +23,9 @@ export interface UserSettingsNotificationsResponse {
|
|||||||
discordEnabled?: boolean;
|
discordEnabled?: boolean;
|
||||||
discordEnabledTypes?: number;
|
discordEnabledTypes?: number;
|
||||||
discordId?: string;
|
discordId?: string;
|
||||||
|
pushbulletAccessToken?: string;
|
||||||
|
pushoverApplicationToken?: string;
|
||||||
|
pushoverUserKey?: string;
|
||||||
telegramEnabled?: boolean;
|
telegramEnabled?: boolean;
|
||||||
telegramBotUsername?: string;
|
telegramBotUsername?: string;
|
||||||
telegramChatId?: string;
|
telegramChatId?: string;
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class JobJellyfinSync {
|
|||||||
newMedia.tmdbId = Number(metadata.ProviderIds.Tmdb ?? null);
|
newMedia.tmdbId = Number(metadata.ProviderIds.Tmdb ?? null);
|
||||||
newMedia.imdbId = metadata.ProviderIds.Imdb;
|
newMedia.imdbId = metadata.ProviderIds.Imdb;
|
||||||
if (newMedia.imdbId && !isNaN(newMedia.tmdbId)) {
|
if (newMedia.imdbId && !isNaN(newMedia.tmdbId)) {
|
||||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
const tmdbMovie = await this.tmdb.getMediaByImdbId({
|
||||||
imdbId: newMedia.imdbId,
|
imdbId: newMedia.imdbId,
|
||||||
});
|
});
|
||||||
newMedia.tmdbId = tmdbMovie.id;
|
newMedia.tmdbId = tmdbMovie.id;
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import schedule from 'node-schedule';
|
import schedule from 'node-schedule';
|
||||||
|
import { MediaServerType } from '../constants/server';
|
||||||
import downloadTracker from '../lib/downloadtracker';
|
import downloadTracker from '../lib/downloadtracker';
|
||||||
import { plexFullScanner, plexRecentScanner } from '../lib/scanners/plex';
|
import { plexFullScanner, plexRecentScanner } from '../lib/scanners/plex';
|
||||||
import { radarrScanner } from '../lib/scanners/radarr';
|
import { radarrScanner } from '../lib/scanners/radarr';
|
||||||
import { sonarrScanner } from '../lib/scanners/sonarr';
|
import { sonarrScanner } from '../lib/scanners/sonarr';
|
||||||
|
import { getSettings, JobId } from '../lib/settings';
|
||||||
import logger from '../logger';
|
import logger from '../logger';
|
||||||
import { jobJellyfinFullSync, jobJellyfinRecentSync } from './jellyfinsync';
|
import { jobJellyfinFullSync, jobJellyfinRecentSync } from './jellyfinsync';
|
||||||
|
|
||||||
interface ScheduledJob {
|
interface ScheduledJob {
|
||||||
id: string;
|
id: JobId;
|
||||||
job: schedule.Job;
|
job: schedule.Job;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'process' | 'command';
|
type: 'process' | 'command';
|
||||||
|
interval: 'short' | 'long' | 'fixed';
|
||||||
running?: () => boolean;
|
running?: () => boolean;
|
||||||
cancelFn?: () => void;
|
cancelFn?: () => void;
|
||||||
}
|
}
|
||||||
@@ -18,72 +21,91 @@ interface ScheduledJob {
|
|||||||
export const scheduledJobs: ScheduledJob[] = [];
|
export const scheduledJobs: ScheduledJob[] = [];
|
||||||
|
|
||||||
export const startJobs = (): void => {
|
export const startJobs = (): void => {
|
||||||
// Run recently added plex scan every 5 minutes
|
const jobs = getSettings().jobs;
|
||||||
scheduledJobs.push({
|
const mediaServerType = getSettings().main.mediaServerType;
|
||||||
id: 'plex-recently-added-scan',
|
|
||||||
name: 'Plex Recently Added Scan',
|
|
||||||
type: 'process',
|
|
||||||
job: schedule.scheduleJob('0 */5 * * * *', () => {
|
|
||||||
logger.info('Starting scheduled job: Plex Recently Added Scan', {
|
|
||||||
label: 'Jobs',
|
|
||||||
});
|
|
||||||
plexRecentScanner.run();
|
|
||||||
}),
|
|
||||||
running: () => plexRecentScanner.status().running,
|
|
||||||
cancelFn: () => plexRecentScanner.cancel(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Run full plex scan every 24 hours
|
if (mediaServerType === MediaServerType.PLEX) {
|
||||||
scheduledJobs.push({
|
// Run recently added plex scan every 5 minutes
|
||||||
id: 'plex-full-scan',
|
scheduledJobs.push({
|
||||||
name: 'Plex Full Library Scan',
|
id: 'plex-recently-added-scan',
|
||||||
type: 'process',
|
name: 'Plex Recently Added Scan',
|
||||||
job: schedule.scheduleJob('0 0 3 * * *', () => {
|
type: 'process',
|
||||||
logger.info('Starting scheduled job: Plex Full Library Scan', {
|
interval: 'short',
|
||||||
label: 'Jobs',
|
job: schedule.scheduleJob(
|
||||||
});
|
jobs['plex-recently-added-scan'].schedule,
|
||||||
plexFullScanner.run();
|
() => {
|
||||||
}),
|
logger.info('Starting scheduled job: Plex Recently Added Scan', {
|
||||||
running: () => plexFullScanner.status().running,
|
label: 'Jobs',
|
||||||
cancelFn: () => plexFullScanner.cancel(),
|
});
|
||||||
});
|
plexRecentScanner.run();
|
||||||
|
}
|
||||||
|
),
|
||||||
|
running: () => plexRecentScanner.status().running,
|
||||||
|
cancelFn: () => plexRecentScanner.cancel(),
|
||||||
|
});
|
||||||
|
|
||||||
// Run recently added jellyfin sync every 5 minutes
|
// Run full plex scan every 24 hours
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'jellyfin-recently-added-sync',
|
id: 'plex-full-scan',
|
||||||
name: 'Jellyfin Recently Added Sync',
|
name: 'Plex Full Library Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
job: schedule.scheduleJob('0 */5 * * * *', () => {
|
interval: 'long',
|
||||||
logger.info('Starting scheduled job: Jellyfin Recently Added Sync', {
|
job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => {
|
||||||
label: 'Jobs',
|
logger.info('Starting scheduled job: Plex Full Library Scan', {
|
||||||
});
|
label: 'Jobs',
|
||||||
jobJellyfinRecentSync.run();
|
});
|
||||||
}),
|
plexFullScanner.run();
|
||||||
running: () => jobJellyfinRecentSync.status().running,
|
}),
|
||||||
cancelFn: () => jobJellyfinRecentSync.cancel(),
|
running: () => plexFullScanner.status().running,
|
||||||
});
|
cancelFn: () => plexFullScanner.cancel(),
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
mediaServerType === MediaServerType.JELLYFIN ||
|
||||||
|
mediaServerType === MediaServerType.EMBY
|
||||||
|
) {
|
||||||
|
// Run recently added jellyfin sync every 5 minutes
|
||||||
|
scheduledJobs.push({
|
||||||
|
id: 'jellyfin-recently-added-sync',
|
||||||
|
name: 'Jellyfin Recently Added Sync',
|
||||||
|
type: 'process',
|
||||||
|
interval: 'long',
|
||||||
|
job: schedule.scheduleJob(
|
||||||
|
jobs['jellyfin-recently-added-sync'].schedule,
|
||||||
|
() => {
|
||||||
|
logger.info('Starting scheduled job: Jellyfin Recently Added Sync', {
|
||||||
|
label: 'Jobs',
|
||||||
|
});
|
||||||
|
jobJellyfinRecentSync.run();
|
||||||
|
}
|
||||||
|
),
|
||||||
|
running: () => jobJellyfinRecentSync.status().running,
|
||||||
|
cancelFn: () => jobJellyfinRecentSync.cancel(),
|
||||||
|
});
|
||||||
|
|
||||||
// Run full jellyfin sync every 24 hours
|
// Run full jellyfin sync every 24 hours
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'jellyfin-full-sync',
|
id: 'jellyfin-full-sync',
|
||||||
name: 'Jellyfin Full Library Sync',
|
name: 'Jellyfin Full Library Sync',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
job: schedule.scheduleJob('0 0 3 * * *', () => {
|
interval: 'long',
|
||||||
logger.info('Starting scheduled job: Jellyfin Full Sync', {
|
job: schedule.scheduleJob(jobs['jellyfin-full-sync'].schedule, () => {
|
||||||
label: 'Jobs',
|
logger.info('Starting scheduled job: Jellyfin Full Sync', {
|
||||||
});
|
label: 'Jobs',
|
||||||
jobJellyfinFullSync.run();
|
});
|
||||||
}),
|
jobJellyfinFullSync.run();
|
||||||
running: () => jobJellyfinFullSync.status().running,
|
}),
|
||||||
cancelFn: () => jobJellyfinFullSync.cancel(),
|
running: () => jobJellyfinFullSync.status().running,
|
||||||
});
|
cancelFn: () => jobJellyfinFullSync.cancel(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Run full radarr scan every 24 hours
|
// Run full radarr scan every 24 hours
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'radarr-scan',
|
id: 'radarr-scan',
|
||||||
name: 'Radarr Scan',
|
name: 'Radarr Scan',
|
||||||
type: 'process',
|
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' });
|
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
|
||||||
radarrScanner.run();
|
radarrScanner.run();
|
||||||
}),
|
}),
|
||||||
@@ -96,7 +118,8 @@ export const startJobs = (): void => {
|
|||||||
id: 'sonarr-scan',
|
id: 'sonarr-scan',
|
||||||
name: 'Sonarr Scan',
|
name: 'Sonarr Scan',
|
||||||
type: 'process',
|
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' });
|
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
|
||||||
sonarrScanner.run();
|
sonarrScanner.run();
|
||||||
}),
|
}),
|
||||||
@@ -104,23 +127,27 @@ export const startJobs = (): void => {
|
|||||||
cancelFn: () => sonarrScanner.cancel(),
|
cancelFn: () => sonarrScanner.cancel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Run download sync
|
// Run download sync every minute
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'download-sync',
|
id: 'download-sync',
|
||||||
name: 'Download Sync',
|
name: 'Download Sync',
|
||||||
type: 'command',
|
type: 'command',
|
||||||
job: schedule.scheduleJob('0 * * * * *', () => {
|
interval: 'fixed',
|
||||||
logger.debug('Starting scheduled job: Download Sync', { label: 'Jobs' });
|
job: schedule.scheduleJob(jobs['download-sync'].schedule, () => {
|
||||||
|
logger.debug('Starting scheduled job: Download Sync', {
|
||||||
|
label: 'Jobs',
|
||||||
|
});
|
||||||
downloadTracker.updateDownloads();
|
downloadTracker.updateDownloads();
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset download sync
|
// Reset download sync everyday at 01:00 am
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'download-sync-reset',
|
id: 'download-sync-reset',
|
||||||
name: 'Download Sync Reset',
|
name: 'Download Sync Reset',
|
||||||
type: 'command',
|
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', {
|
logger.info('Starting scheduled job: Download Sync Reset', {
|
||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class Cache {
|
|||||||
|
|
||||||
class CacheManager {
|
class CacheManager {
|
||||||
private availableCaches: Record<AvailableCacheIds, Cache> = {
|
private availableCaches: Record<AvailableCacheIds, Cache> = {
|
||||||
tmdb: new Cache('tmdb', 'TMDb API', {
|
tmdb: new Cache('tmdb', 'The Movie Database API', {
|
||||||
stdTtl: 21600,
|
stdTtl: 21600,
|
||||||
checkPeriod: 60 * 30,
|
checkPeriod: 60 * 30,
|
||||||
}),
|
}),
|
||||||
@@ -54,7 +54,7 @@ class CacheManager {
|
|||||||
stdTtl: 21600,
|
stdTtl: 21600,
|
||||||
checkPeriod: 60 * 30,
|
checkPeriod: 60 * 30,
|
||||||
}),
|
}),
|
||||||
plexguid: new Cache('plexguid', 'Plex GUID Cache', {
|
plexguid: new Cache('plexguid', 'Plex GUID', {
|
||||||
stdTtl: 86400 * 7, // 1 week cache
|
stdTtl: 86400 * 7, // 1 week cache
|
||||||
checkPeriod: 60 * 30,
|
checkPeriod: 60 * 30,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -76,23 +76,32 @@ class DownloadTracker {
|
|||||||
url: RadarrAPI.buildUrl(server, '/api/v3'),
|
url: RadarrAPI.buildUrl(server, '/api/v3'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const queueItems = await radarr.getQueue();
|
try {
|
||||||
|
const queueItems = await radarr.getQueue();
|
||||||
|
|
||||||
this.radarrServers[server.id] = queueItems.map((item) => ({
|
this.radarrServers[server.id] = queueItems.map((item) => ({
|
||||||
externalId: item.movieId,
|
externalId: item.movieId,
|
||||||
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
|
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
|
||||||
mediaType: MediaType.MOVIE,
|
mediaType: MediaType.MOVIE,
|
||||||
size: item.size,
|
size: item.size,
|
||||||
sizeLeft: item.sizeleft,
|
sizeLeft: item.sizeleft,
|
||||||
status: item.status,
|
status: item.status,
|
||||||
timeLeft: item.timeleft,
|
timeLeft: item.timeleft,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (queueItems.length > 0) {
|
if (queueItems.length > 0) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Found ${queueItems.length} item(s) in progress on Radarr server: ${server.name}`,
|
`Found ${queueItems.length} item(s) in progress on Radarr server: ${server.name}`,
|
||||||
{ label: 'Download Tracker' }
|
{ 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(
|
Promise.all(
|
||||||
filteredServers.map(async (server) => {
|
filteredServers.map(async (server) => {
|
||||||
if (server.syncEnabled) {
|
if (server.syncEnabled) {
|
||||||
const radarr = new SonarrAPI({
|
const sonarr = new SonarrAPI({
|
||||||
apiKey: server.apiKey,
|
apiKey: server.apiKey,
|
||||||
url: SonarrAPI.buildUrl(server, '/api/v3'),
|
url: SonarrAPI.buildUrl(server, '/api/v3'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const queueItems = await radarr.getQueue();
|
try {
|
||||||
|
const queueItems = await sonarr.getQueue();
|
||||||
|
|
||||||
this.sonarrServers[server.id] = queueItems.map((item) => ({
|
this.sonarrServers[server.id] = queueItems.map((item) => ({
|
||||||
externalId: item.seriesId,
|
externalId: item.seriesId,
|
||||||
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
|
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
|
||||||
mediaType: MediaType.TV,
|
mediaType: MediaType.TV,
|
||||||
size: item.size,
|
size: item.size,
|
||||||
sizeLeft: item.sizeleft,
|
sizeLeft: item.sizeleft,
|
||||||
status: item.status,
|
status: item.status,
|
||||||
timeLeft: item.timeleft,
|
timeLeft: item.timeleft,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (queueItems.length > 0) {
|
if (queueItems.length > 0) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Found ${queueItems.length} item(s) in progress on Sonarr server: ${server.name}`,
|
`Found ${queueItems.length} item(s) in progress on Sonarr server: ${server.name}`,
|
||||||
{ label: 'Download Tracker' }
|
{ label: 'Download Tracker' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error(
|
||||||
|
`Unable to get queue from Sonarr server: ${server.name}`,
|
||||||
|
{
|
||||||
|
label: 'Download Tracker',
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Duplicate this data to matching servers
|
// Duplicate this data to matching servers
|
||||||
const matchingServers = settings.sonarr.filter(
|
const matchingServers = settings.sonarr.filter(
|
||||||
(rs) =>
|
(ss) =>
|
||||||
rs.hostname === server.hostname &&
|
ss.hostname === server.hostname &&
|
||||||
rs.port === server.port &&
|
ss.port === server.port &&
|
||||||
rs.baseUrl === server.baseUrl &&
|
ss.baseUrl === server.baseUrl &&
|
||||||
rs.id !== server.id
|
ss.id !== server.id
|
||||||
);
|
);
|
||||||
|
|
||||||
if (matchingServers.length > 0) {
|
if (matchingServers.length > 0) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
import MailMessage from 'nodemailer/lib/mailer/mail-message';
|
|
||||||
import * as openpgp from 'openpgp';
|
import * as openpgp from 'openpgp';
|
||||||
import { Transform, TransformCallback } from 'stream';
|
import { Transform, TransformCallback } from 'stream';
|
||||||
|
import logger from '../../logger';
|
||||||
|
|
||||||
interface EncryptorOptions {
|
interface EncryptorOptions {
|
||||||
signingKey?: string;
|
signingKey?: string;
|
||||||
@@ -26,7 +26,7 @@ class PGPEncryptor extends Transform {
|
|||||||
|
|
||||||
// just save the whole message
|
// just save the whole message
|
||||||
_transform = (
|
_transform = (
|
||||||
chunk: Uint8Array,
|
chunk: any,
|
||||||
_encoding: BufferEncoding,
|
_encoding: BufferEncoding,
|
||||||
callback: TransformCallback
|
callback: TransformCallback
|
||||||
): void => {
|
): void => {
|
||||||
@@ -37,146 +37,164 @@ class PGPEncryptor extends Transform {
|
|||||||
|
|
||||||
// Actually do stuff
|
// Actually do stuff
|
||||||
_flush = async (callback: TransformCallback): Promise<void> => {
|
_flush = async (callback: TransformCallback): Promise<void> => {
|
||||||
// Reconstruct message as buffer
|
|
||||||
const message = Buffer.concat(this._messageChunks, this._messageLength);
|
const message = Buffer.concat(this._messageChunks, this._messageLength);
|
||||||
const validPublicKeys = await Promise.all(
|
|
||||||
this._encryptionKeys.map((armoredKey) => openpgp.readKey({ armoredKey }))
|
|
||||||
);
|
|
||||||
let privateKey: openpgp.PrivateKey | undefined;
|
|
||||||
|
|
||||||
// Just return the message if there is no one to encrypt for
|
try {
|
||||||
if (!validPublicKeys.length) {
|
// Reconstruct message as buffer
|
||||||
this.push(message);
|
const validPublicKeys = await Promise.all(
|
||||||
return callback();
|
this._encryptionKeys.map((armoredKey) =>
|
||||||
}
|
openpgp.readKey({ armoredKey })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let privateKey: openpgp.PrivateKey | undefined;
|
||||||
|
|
||||||
// Only sign the message if private key and password exist
|
// Just return the message if there is no one to encrypt for
|
||||||
if (this._signingKey && this._password) {
|
if (!validPublicKeys.length) {
|
||||||
privateKey = await openpgp.readPrivateKey({
|
this.push(message);
|
||||||
armoredKey: this._signingKey,
|
return callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only sign the message if private key and password exist
|
||||||
|
if (this._signingKey && this._password) {
|
||||||
|
privateKey = await openpgp.decryptKey({
|
||||||
|
privateKey: await openpgp.readPrivateKey({
|
||||||
|
armoredKey: this._signingKey,
|
||||||
|
}),
|
||||||
|
passphrase: this._password,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailPartDelimiter = '\r\n\r\n';
|
||||||
|
const messageParts = message.toString().split(emailPartDelimiter);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In this loop original headers are split up into two parts,
|
||||||
|
* one for the email that is sent
|
||||||
|
* and one for the encrypted content
|
||||||
|
*/
|
||||||
|
const header = messageParts.shift() as string;
|
||||||
|
const emailHeaders: string[][] = [];
|
||||||
|
const contentHeaders: string[][] = [];
|
||||||
|
const linesInHeader = header.split('\r\n');
|
||||||
|
let previousHeader: string[] = [];
|
||||||
|
for (let i = 0; i < linesInHeader.length; i++) {
|
||||||
|
const line = linesInHeader[i];
|
||||||
|
/**
|
||||||
|
* If it is a multi-line header (current line starts with whitespace)
|
||||||
|
* or it's the first line in the iteration
|
||||||
|
* add the current line with previous header and move on
|
||||||
|
*/
|
||||||
|
if (/^\s/.test(line) || i === 0) {
|
||||||
|
previousHeader.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is done to prevent the last header
|
||||||
|
* from being missed
|
||||||
|
*/
|
||||||
|
if (i === linesInHeader.length - 1) {
|
||||||
|
previousHeader.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We need to seperate the actual content headers
|
||||||
|
* so that we can add it as a header for the encrypted content
|
||||||
|
* So that the content will be displayed properly after decryption
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
/^(content-type|content-transfer-encoding):/i.test(previousHeader[0])
|
||||||
|
) {
|
||||||
|
contentHeaders.push(previousHeader);
|
||||||
|
} else {
|
||||||
|
emailHeaders.push(previousHeader);
|
||||||
|
}
|
||||||
|
previousHeader = [line];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a new boundary for the email content
|
||||||
|
const boundary = 'nm_' + randomBytes(14).toString('hex');
|
||||||
|
/**
|
||||||
|
* Concatenate everything into single strings
|
||||||
|
* and add pgp headers to the email headers
|
||||||
|
*/
|
||||||
|
const emailHeadersRaw =
|
||||||
|
emailHeaders.map((line) => line.join('\r\n')).join('\r\n') +
|
||||||
|
'\r\n' +
|
||||||
|
'Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";' +
|
||||||
|
'\r\n' +
|
||||||
|
' boundary="' +
|
||||||
|
boundary +
|
||||||
|
'"' +
|
||||||
|
'\r\n' +
|
||||||
|
'Content-Description: OpenPGP encrypted message' +
|
||||||
|
'\r\n' +
|
||||||
|
'Content-Transfer-Encoding: 7bit';
|
||||||
|
const contentHeadersRaw = contentHeaders
|
||||||
|
.map((line) => line.join('\r\n'))
|
||||||
|
.join('\r\n');
|
||||||
|
|
||||||
|
const encryptedMessage = await openpgp.encrypt({
|
||||||
|
message: await openpgp.createMessage({
|
||||||
|
text:
|
||||||
|
contentHeadersRaw +
|
||||||
|
emailPartDelimiter +
|
||||||
|
messageParts.join(emailPartDelimiter),
|
||||||
|
}),
|
||||||
|
encryptionKeys: validPublicKeys,
|
||||||
|
signingKeys: privateKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
await openpgp.decryptKey({ privateKey, passphrase: this._password });
|
const body =
|
||||||
|
'--' +
|
||||||
|
boundary +
|
||||||
|
'\r\n' +
|
||||||
|
'Content-Type: application/pgp-encrypted\r\n' +
|
||||||
|
'Content-Transfer-Encoding: 7bit\r\n' +
|
||||||
|
'\r\n' +
|
||||||
|
'Version: 1\r\n' +
|
||||||
|
'\r\n' +
|
||||||
|
'--' +
|
||||||
|
boundary +
|
||||||
|
'\r\n' +
|
||||||
|
'Content-Type: application/octet-stream; name=encrypted.asc\r\n' +
|
||||||
|
'Content-Disposition: inline; filename=encrypted.asc\r\n' +
|
||||||
|
'Content-Transfer-Encoding: 7bit\r\n' +
|
||||||
|
'\r\n' +
|
||||||
|
encryptedMessage +
|
||||||
|
'\r\n--' +
|
||||||
|
boundary +
|
||||||
|
'--\r\n';
|
||||||
|
|
||||||
|
this.push(Buffer.from(emailHeadersRaw + emailPartDelimiter + body));
|
||||||
|
callback();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
'Something went wrong while encrypting email message with OpenPGP. Sending email without encryption',
|
||||||
|
{
|
||||||
|
label: 'Notifications',
|
||||||
|
errorMessage: e.message,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.push(message);
|
||||||
|
callback();
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailPartDelimiter = '\r\n\r\n';
|
|
||||||
const messageParts = message.toString().split(emailPartDelimiter);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In this loop original headers are split up into two parts,
|
|
||||||
* one for the email that is sent
|
|
||||||
* and one for the encrypted content
|
|
||||||
*/
|
|
||||||
const header = messageParts.shift() as string;
|
|
||||||
const emailHeaders: string[][] = [];
|
|
||||||
const contentHeaders: string[][] = [];
|
|
||||||
const linesInHeader = header.split('\r\n');
|
|
||||||
let previousHeader: string[] = [];
|
|
||||||
for (let i = 0; i < linesInHeader.length; i++) {
|
|
||||||
const line = linesInHeader[i];
|
|
||||||
/**
|
|
||||||
* If it is a multi-line header (current line starts with whitespace)
|
|
||||||
* or it's the first line in the iteration
|
|
||||||
* add the current line with previous header and move on
|
|
||||||
*/
|
|
||||||
if (/^\s/.test(line) || i === 0) {
|
|
||||||
previousHeader.push(line);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is done to prevent the last header
|
|
||||||
* from being missed
|
|
||||||
*/
|
|
||||||
if (i === linesInHeader.length - 1) {
|
|
||||||
previousHeader.push(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* We need to seperate the actual content headers
|
|
||||||
* so that we can add it as a header for the encrypted content
|
|
||||||
* So that the content will be displayed properly after decryption
|
|
||||||
*/
|
|
||||||
if (
|
|
||||||
/^(content-type|content-transfer-encoding):/i.test(previousHeader[0])
|
|
||||||
) {
|
|
||||||
contentHeaders.push(previousHeader);
|
|
||||||
} else {
|
|
||||||
emailHeaders.push(previousHeader);
|
|
||||||
}
|
|
||||||
previousHeader = [line];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a new boundary for the email content
|
|
||||||
const boundary = 'nm_' + randomBytes(14).toString('hex');
|
|
||||||
/**
|
|
||||||
* Concatenate everything into single strings
|
|
||||||
* and add pgp headers to the email headers
|
|
||||||
*/
|
|
||||||
const emailHeadersRaw =
|
|
||||||
emailHeaders.map((line) => line.join('\r\n')).join('\r\n') +
|
|
||||||
'\r\n' +
|
|
||||||
'Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";' +
|
|
||||||
'\r\n' +
|
|
||||||
' boundary="' +
|
|
||||||
boundary +
|
|
||||||
'"' +
|
|
||||||
'\r\n' +
|
|
||||||
'Content-Description: OpenPGP encrypted message' +
|
|
||||||
'\r\n' +
|
|
||||||
'Content-Transfer-Encoding: 7bit';
|
|
||||||
const contentHeadersRaw = contentHeaders
|
|
||||||
.map((line) => line.join('\r\n'))
|
|
||||||
.join('\r\n');
|
|
||||||
|
|
||||||
const encryptedMessage = await openpgp.encrypt({
|
|
||||||
message: await openpgp.createMessage({
|
|
||||||
text:
|
|
||||||
contentHeadersRaw +
|
|
||||||
emailPartDelimiter +
|
|
||||||
messageParts.join(emailPartDelimiter),
|
|
||||||
}),
|
|
||||||
encryptionKeys: validPublicKeys,
|
|
||||||
signingKeys: privateKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
const body =
|
|
||||||
'--' +
|
|
||||||
boundary +
|
|
||||||
'\r\n' +
|
|
||||||
'Content-Type: application/pgp-encrypted\r\n' +
|
|
||||||
'Content-Transfer-Encoding: 7bit\r\n' +
|
|
||||||
'\r\n' +
|
|
||||||
'Version: 1\r\n' +
|
|
||||||
'\r\n' +
|
|
||||||
'--' +
|
|
||||||
boundary +
|
|
||||||
'\r\n' +
|
|
||||||
'Content-Type: application/octet-stream; name=encrypted.asc\r\n' +
|
|
||||||
'Content-Disposition: inline; filename=encrypted.asc\r\n' +
|
|
||||||
'Content-Transfer-Encoding: 7bit\r\n' +
|
|
||||||
'\r\n' +
|
|
||||||
encryptedMessage +
|
|
||||||
'\r\n--' +
|
|
||||||
boundary +
|
|
||||||
'--\r\n';
|
|
||||||
|
|
||||||
this.push(Buffer.from(emailHeadersRaw + emailPartDelimiter + body));
|
|
||||||
callback();
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const openpgpEncrypt = (options: EncryptorOptions) => {
|
export const openpgpEncrypt = (options: EncryptorOptions) => {
|
||||||
return function (mail: MailMessage, callback: () => unknown): void {
|
return function (mail: any, callback: () => unknown): void {
|
||||||
if (!options.encryptionKeys.length) {
|
if (!options.encryptionKeys.length) {
|
||||||
setImmediate(callback);
|
setImmediate(callback);
|
||||||
}
|
}
|
||||||
mail.message.transform(
|
mail.message.transform(
|
||||||
new PGPEncryptor({
|
() =>
|
||||||
signingKey: options.signingKey,
|
new PGPEncryptor({
|
||||||
password: options.password,
|
signingKey: options.signingKey,
|
||||||
encryptionKeys: options.encryptionKeys,
|
password: options.password,
|
||||||
})
|
encryptionKeys: options.encryptionKeys,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
setImmediate(callback);
|
setImmediate(callback);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import { Notification } from '..';
|
import { Notification } from '..';
|
||||||
|
import type Issue from '../../../entity/Issue';
|
||||||
|
import IssueComment from '../../../entity/IssueComment';
|
||||||
import Media from '../../../entity/Media';
|
import Media from '../../../entity/Media';
|
||||||
import { MediaRequest } from '../../../entity/MediaRequest';
|
import { MediaRequest } from '../../../entity/MediaRequest';
|
||||||
import { User } from '../../../entity/User';
|
import { User } from '../../../entity/User';
|
||||||
import { NotificationAgentConfig } from '../../settings';
|
import { NotificationAgentConfig } from '../../settings';
|
||||||
|
|
||||||
export interface NotificationPayload {
|
export interface NotificationPayload {
|
||||||
|
event?: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
|
notifyAdmin: boolean;
|
||||||
notifyUser?: User;
|
notifyUser?: User;
|
||||||
media?: Media;
|
media?: Media;
|
||||||
image?: string;
|
image?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
extra?: { name: string; value: string }[];
|
extra?: { name: string; value: string }[];
|
||||||
request?: MediaRequest;
|
request?: MediaRequest;
|
||||||
|
issue?: Issue;
|
||||||
|
comment?: IssueComment;
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class BaseAgent<T extends NotificationAgentConfig> {
|
export abstract class BaseAgent<T extends NotificationAgentConfig> {
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { getRepository } from 'typeorm';
|
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 { User } from '../../../entity/User';
|
||||||
import logger from '../../../logger';
|
import logger from '../../../logger';
|
||||||
import { Permission } from '../../permissions';
|
|
||||||
import {
|
import {
|
||||||
getSettings,
|
getSettings,
|
||||||
NotificationAgentDiscord,
|
NotificationAgentDiscord,
|
||||||
@@ -91,7 +95,8 @@ interface DiscordWebhookPayload {
|
|||||||
|
|
||||||
class DiscordAgent
|
class DiscordAgent
|
||||||
extends BaseAgent<NotificationAgentDiscord>
|
extends BaseAgent<NotificationAgentDiscord>
|
||||||
implements NotificationAgent {
|
implements NotificationAgent
|
||||||
|
{
|
||||||
protected getSettings(): NotificationAgentDiscord {
|
protected getSettings(): NotificationAgentDiscord {
|
||||||
if (this.settings) {
|
if (this.settings) {
|
||||||
return this.settings;
|
return this.settings;
|
||||||
@@ -106,9 +111,9 @@ class DiscordAgent
|
|||||||
type: Notification,
|
type: Notification,
|
||||||
payload: NotificationPayload
|
payload: NotificationPayload
|
||||||
): DiscordRichEmbed {
|
): DiscordRichEmbed {
|
||||||
const settings = getSettings();
|
const { applicationUrl } = getSettings().main;
|
||||||
let color = EmbedColors.DARK_PURPLE;
|
|
||||||
|
|
||||||
|
let color = EmbedColors.DARK_PURPLE;
|
||||||
const fields: Field[] = [];
|
const fields: Field[] = [];
|
||||||
|
|
||||||
if (payload.request) {
|
if (payload.request) {
|
||||||
@@ -117,56 +122,94 @@ class DiscordAgent
|
|||||||
value: payload.request.requestedBy.displayName,
|
value: payload.request.requestedBy.displayName,
|
||||||
inline: true,
|
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) {
|
for (const extra of payload.extra ?? []) {
|
||||||
case Notification.MEDIA_PENDING:
|
fields.push({
|
||||||
color = EmbedColors.ORANGE;
|
name: extra.name,
|
||||||
fields.push({
|
value: extra.value,
|
||||||
name: 'Status',
|
inline: true,
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const url =
|
const url = applicationUrl
|
||||||
settings.main.applicationUrl && payload.media
|
? payload.issue
|
||||||
? `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
? `${applicationUrl}/issues/${payload.issue.id}`
|
||||||
: undefined;
|
: payload.media
|
||||||
|
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||||
|
: undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: payload.subject,
|
title: payload.subject,
|
||||||
@@ -174,18 +217,12 @@ class DiscordAgent
|
|||||||
description: payload.message,
|
description: payload.message,
|
||||||
color,
|
color,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
author: {
|
author: payload.event
|
||||||
name: settings.main.applicationTitle,
|
? {
|
||||||
url: settings.main.applicationUrl,
|
name: payload.event,
|
||||||
},
|
}
|
||||||
fields: [
|
: undefined,
|
||||||
...fields,
|
fields,
|
||||||
// If we have extra data, map it to fields for discord notifications
|
|
||||||
...(payload.extra ?? []).map((extra) => ({
|
|
||||||
name: extra.name,
|
|
||||||
value: extra.value,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
thumbnail: {
|
thumbnail: {
|
||||||
url: payload.image,
|
url: payload.image,
|
||||||
},
|
},
|
||||||
@@ -218,54 +255,55 @@ class DiscordAgent
|
|||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
});
|
});
|
||||||
|
|
||||||
let content = undefined;
|
const userMentions: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (payload.notifyUser) {
|
if (settings.options.enableMentions) {
|
||||||
// Mention user who submitted the request
|
if (payload.notifyUser) {
|
||||||
if (
|
if (
|
||||||
payload.notifyUser.settings?.hasNotificationType(
|
payload.notifyUser.settings?.hasNotificationType(
|
||||||
NotificationAgentKey.DISCORD,
|
NotificationAgentKey.DISCORD,
|
||||||
type
|
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
|
|
||||||
const userRepository = getRepository(User);
|
|
||||||
const users = await userRepository.find();
|
|
||||||
|
|
||||||
content = users
|
if (payload.notifyAdmin) {
|
||||||
.filter(
|
const userRepository = getRepository(User);
|
||||||
(user) =>
|
const users = await userRepository.find();
|
||||||
user.hasPermission(Permission.MANAGE_REQUESTS) &&
|
|
||||||
user.settings?.hasNotificationType(
|
userMentions.push(
|
||||||
NotificationAgentKey.DISCORD,
|
...users
|
||||||
type
|
.filter(
|
||||||
) &&
|
(user) =>
|
||||||
user.settings?.discordId &&
|
user.settings?.hasNotificationType(
|
||||||
// Check if it's the user's own auto-approved request
|
NotificationAgentKey.DISCORD,
|
||||||
(type !== Notification.MEDIA_AUTO_APPROVED ||
|
type
|
||||||
user.id !== payload.request?.requestedBy.id)
|
) &&
|
||||||
)
|
user.settings.discordId &&
|
||||||
.map((user) => `<@${user.settings?.discordId}>`)
|
shouldSendAdminNotification(type, user, payload)
|
||||||
.join(' ');
|
)
|
||||||
|
.map((user) => `<@${user.settings?.discordId}>`)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await axios.post(settings.options.webhookUrl, {
|
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,
|
avatar_url: settings.options.botAvatarUrl,
|
||||||
embeds: [this.buildEmbed(type, payload)],
|
embeds: [this.buildEmbed(type, payload)],
|
||||||
content,
|
content: userMentions.join(' '),
|
||||||
} as DiscordWebhookPayload);
|
} as DiscordWebhookPayload);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error sending Discord notification', {
|
logger.error('Error sending Discord notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
mentions: content,
|
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { EmailOptions } from 'email-templates';
|
import { EmailOptions } from 'email-templates';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { getRepository } from 'typeorm';
|
import { getRepository } from 'typeorm';
|
||||||
import { Notification } from '..';
|
import { Notification, shouldSendAdminNotification } from '..';
|
||||||
|
import { IssueType, IssueTypeName } from '../../../constants/issue';
|
||||||
import { MediaType } from '../../../constants/media';
|
import { MediaType } from '../../../constants/media';
|
||||||
import { User } from '../../../entity/User';
|
import { User } from '../../../entity/User';
|
||||||
import logger from '../../../logger';
|
import logger from '../../../logger';
|
||||||
import PreparedEmail from '../../email';
|
import PreparedEmail from '../../email';
|
||||||
import { Permission } from '../../permissions';
|
|
||||||
import {
|
import {
|
||||||
getSettings,
|
getSettings,
|
||||||
NotificationAgentEmail,
|
NotificationAgentEmail,
|
||||||
@@ -16,7 +16,8 @@ import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
|||||||
|
|
||||||
class EmailAgent
|
class EmailAgent
|
||||||
extends BaseAgent<NotificationAgentEmail>
|
extends BaseAgent<NotificationAgentEmail>
|
||||||
implements NotificationAgent {
|
implements NotificationAgent
|
||||||
|
{
|
||||||
protected getSettings(): NotificationAgentEmail {
|
protected getSettings(): NotificationAgentEmail {
|
||||||
if (this.settings) {
|
if (this.settings) {
|
||||||
return this.settings;
|
return this.settings;
|
||||||
@@ -45,7 +46,8 @@ class EmailAgent
|
|||||||
private buildMessage(
|
private buildMessage(
|
||||||
type: Notification,
|
type: Notification,
|
||||||
payload: NotificationPayload,
|
payload: NotificationPayload,
|
||||||
toEmail: string
|
recipientEmail: string,
|
||||||
|
recipientName?: string
|
||||||
): EmailOptions | undefined {
|
): EmailOptions | undefined {
|
||||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||||
|
|
||||||
@@ -53,69 +55,59 @@ class EmailAgent
|
|||||||
return {
|
return {
|
||||||
template: path.join(__dirname, '../../../templates/email/test-email'),
|
template: path.join(__dirname, '../../../templates/email/test-email'),
|
||||||
message: {
|
message: {
|
||||||
to: toEmail,
|
to: recipientEmail,
|
||||||
},
|
},
|
||||||
locals: {
|
locals: {
|
||||||
body: payload.message,
|
body: payload.message,
|
||||||
applicationUrl,
|
applicationUrl,
|
||||||
applicationTitle,
|
applicationTitle,
|
||||||
|
recipientName,
|
||||||
|
recipientEmail,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.media) {
|
const mediaType = payload.media
|
||||||
let requestType = '';
|
? payload.media.mediaType === MediaType.MOVIE
|
||||||
|
? 'movie'
|
||||||
|
: 'series'
|
||||||
|
: undefined;
|
||||||
|
const is4k = payload.request?.is4k;
|
||||||
|
|
||||||
|
if (payload.request) {
|
||||||
let body = '';
|
let body = '';
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case Notification.MEDIA_PENDING:
|
case Notification.MEDIA_PENDING:
|
||||||
requestType = `New ${
|
body = `A new request for the following ${mediaType} ${
|
||||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
is4k ? 'in 4K ' : ''
|
||||||
} Request`;
|
}is pending approval:`;
|
||||||
body = `A user has requested a new ${
|
|
||||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
|
||||||
}!`;
|
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_APPROVED:
|
case Notification.MEDIA_APPROVED:
|
||||||
requestType = `${
|
body = `Your request for the following ${mediaType} ${
|
||||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
is4k ? 'in 4K ' : ''
|
||||||
} Request Approved`;
|
}has been approved:`;
|
||||||
body = `Your request for the following ${
|
|
||||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
|
||||||
} has been approved:`;
|
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_AUTO_APPROVED:
|
case Notification.MEDIA_AUTO_APPROVED:
|
||||||
requestType = `${
|
body = `A new request for the following ${mediaType} ${
|
||||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
is4k ? 'in 4K ' : ''
|
||||||
} Request Automatically Approved`;
|
}has been automatically approved:`;
|
||||||
body = `A new request for the following ${
|
|
||||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
|
||||||
} has been automatically approved:`;
|
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_AVAILABLE:
|
case Notification.MEDIA_AVAILABLE:
|
||||||
requestType = `${
|
body = `Your request for the following ${mediaType} ${
|
||||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
is4k ? 'in 4K ' : ''
|
||||||
} Now Available`;
|
}is now available:`;
|
||||||
body = `The following ${
|
|
||||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
|
||||||
} you requested is now available!`;
|
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_DECLINED:
|
case Notification.MEDIA_DECLINED:
|
||||||
requestType = `${
|
body = `Your request for the following ${mediaType} ${
|
||||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
is4k ? 'in 4K ' : ''
|
||||||
} Request Declined`;
|
}was declined:`;
|
||||||
body = `Your request for the following ${
|
|
||||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
|
||||||
} was declined:`;
|
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_FAILED:
|
case Notification.MEDIA_FAILED:
|
||||||
requestType = `Failed ${
|
body = `A request for the following ${mediaType} ${
|
||||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
is4k ? 'in 4K ' : ''
|
||||||
} Request`;
|
}failed to be added to ${
|
||||||
body = `A new request for the following ${
|
payload.media?.mediaType === MediaType.MOVIE ? 'Radarr' : 'Sonarr'
|
||||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
|
||||||
} could not be added to ${
|
|
||||||
payload.media?.mediaType === MediaType.TV ? 'Sonarr' : 'Radarr'
|
|
||||||
}:`;
|
}:`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -126,22 +118,69 @@ class EmailAgent
|
|||||||
'../../../templates/email/media-request'
|
'../../../templates/email/media-request'
|
||||||
),
|
),
|
||||||
message: {
|
message: {
|
||||||
to: toEmail,
|
to: recipientEmail,
|
||||||
},
|
},
|
||||||
locals: {
|
locals: {
|
||||||
requestType,
|
event: payload.event,
|
||||||
body,
|
body,
|
||||||
mediaName: payload.subject,
|
mediaName: payload.subject,
|
||||||
mediaPlot: payload.message,
|
|
||||||
mediaExtra: payload.extra ?? [],
|
mediaExtra: payload.extra ?? [],
|
||||||
imageUrl: payload.image,
|
imageUrl: payload.image,
|
||||||
timestamp: new Date().toTimeString(),
|
timestamp: new Date().toTimeString(),
|
||||||
requestedBy: payload.request?.requestedBy.displayName,
|
requestedBy: payload.request.requestedBy.displayName,
|
||||||
actionUrl: applicationUrl
|
actionUrl: applicationUrl
|
||||||
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
|
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
|
||||||
: undefined,
|
: undefined,
|
||||||
applicationUrl,
|
applicationUrl,
|
||||||
applicationTitle,
|
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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -154,7 +193,6 @@ class EmailAgent
|
|||||||
payload: NotificationPayload
|
payload: NotificationPayload
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (payload.notifyUser) {
|
if (payload.notifyUser) {
|
||||||
// Send notification to the user who submitted the request
|
|
||||||
if (
|
if (
|
||||||
!payload.notifyUser.settings ||
|
!payload.notifyUser.settings ||
|
||||||
// Check if user has email notifications enabled and fallback to true if undefined
|
// Check if user has email notifications enabled and fallback to true if undefined
|
||||||
@@ -178,7 +216,12 @@ class EmailAgent
|
|||||||
payload.notifyUser.settings?.pgpKey
|
payload.notifyUser.settings?.pgpKey
|
||||||
);
|
);
|
||||||
await email.send(
|
await email.send(
|
||||||
this.buildMessage(type, payload, payload.notifyUser.email)
|
this.buildMessage(
|
||||||
|
type,
|
||||||
|
payload,
|
||||||
|
payload.notifyUser.email,
|
||||||
|
payload.notifyUser.displayName
|
||||||
|
)
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error sending email notification', {
|
logger.error('Error sending email notification', {
|
||||||
@@ -192,8 +235,9 @@ class EmailAgent
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
// Send notifications to all users with the Manage Requests permission
|
|
||||||
|
if (payload.notifyAdmin) {
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
const users = await userRepository.find();
|
const users = await userRepository.find();
|
||||||
|
|
||||||
@@ -201,7 +245,6 @@ class EmailAgent
|
|||||||
users
|
users
|
||||||
.filter(
|
.filter(
|
||||||
(user) =>
|
(user) =>
|
||||||
user.hasPermission(Permission.MANAGE_REQUESTS) &&
|
|
||||||
(!user.settings ||
|
(!user.settings ||
|
||||||
// Check if user has email notifications enabled and fallback to true if undefined
|
// Check if user has email notifications enabled and fallback to true if undefined
|
||||||
// since email should default to true
|
// since email should default to true
|
||||||
@@ -210,9 +253,7 @@ class EmailAgent
|
|||||||
type
|
type
|
||||||
) ??
|
) ??
|
||||||
true)) &&
|
true)) &&
|
||||||
// Check if it's the user's own auto-approved request
|
shouldSendAdminNotification(type, user, payload)
|
||||||
(type !== Notification.MEDIA_AUTO_APPROVED ||
|
|
||||||
user.id !== payload.request?.requestedBy.id)
|
|
||||||
)
|
)
|
||||||
.map(async (user) => {
|
.map(async (user) => {
|
||||||
logger.debug('Sending email notification', {
|
logger.debug('Sending email notification', {
|
||||||
@@ -227,7 +268,9 @@ class EmailAgent
|
|||||||
this.getSettings(),
|
this.getSettings(),
|
||||||
user.settings?.pgpKey
|
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) {
|
} catch (e) {
|
||||||
logger.error('Error sending email notification', {
|
logger.error('Error sending email notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
|
|||||||
148
server/lib/notifications/agents/gotify.ts
Normal file
148
server/lib/notifications/agents/gotify.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { hasNotificationType, Notification } from '..';
|
||||||
|
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||||
|
import logger from '../../../logger';
|
||||||
|
import { getSettings, NotificationAgentGotify } from '../../settings';
|
||||||
|
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||||
|
|
||||||
|
interface GotifyPayload {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
priority: number;
|
||||||
|
extras: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
class GotifyAgent
|
||||||
|
extends BaseAgent<NotificationAgentGotify>
|
||||||
|
implements NotificationAgent
|
||||||
|
{
|
||||||
|
protected getSettings(): NotificationAgentGotify {
|
||||||
|
if (this.settings) {
|
||||||
|
return this.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
return settings.notifications.agents.gotify;
|
||||||
|
}
|
||||||
|
|
||||||
|
public shouldSend(): boolean {
|
||||||
|
const settings = this.getSettings();
|
||||||
|
|
||||||
|
if (settings.enabled && settings.options.url && settings.options.token) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNotificationPayload(
|
||||||
|
type: Notification,
|
||||||
|
payload: NotificationPayload
|
||||||
|
): GotifyPayload {
|
||||||
|
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||||
|
let priority = 0;
|
||||||
|
|
||||||
|
const title = payload.event
|
||||||
|
? `${payload.event} - ${payload.subject}`
|
||||||
|
: payload.subject;
|
||||||
|
let message = payload.message ?? '';
|
||||||
|
|
||||||
|
if (payload.request) {
|
||||||
|
message += `\n\nRequested By: ${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 += `\nRequest Status: ${status}`;
|
||||||
|
}
|
||||||
|
} else if (payload.comment) {
|
||||||
|
message += `\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message}`;
|
||||||
|
} else if (payload.issue) {
|
||||||
|
message += `\n\nReported By: ${payload.issue.createdBy.displayName}`;
|
||||||
|
message += `\nIssue Type: ${IssueTypeName[payload.issue.issueType]}`;
|
||||||
|
message += `\nIssue Status: ${
|
||||||
|
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
|
||||||
|
}`;
|
||||||
|
|
||||||
|
if (type == Notification.ISSUE_CREATED) {
|
||||||
|
priority = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const extra of payload.extra ?? []) {
|
||||||
|
message += `\n\n**${extra.name}**\n${extra.value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (applicationUrl && payload.media) {
|
||||||
|
const actionUrl = `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
|
||||||
|
message += `\n\nOpen in ${applicationTitle}(${actionUrl})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
extras: {
|
||||||
|
'client::display': {
|
||||||
|
contentType: 'text/markdown',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
priority,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async send(
|
||||||
|
type: Notification,
|
||||||
|
payload: NotificationPayload
|
||||||
|
): Promise<boolean> {
|
||||||
|
const settings = this.getSettings();
|
||||||
|
|
||||||
|
if (!hasNotificationType(type, settings.types ?? 0)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Sending Gotify notification', {
|
||||||
|
label: 'Notifications',
|
||||||
|
type: Notification[type],
|
||||||
|
subject: payload.subject,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const endpoint = `${settings.options.url}/message?token=${settings.options.token}`;
|
||||||
|
const notificationPayload = this.getNotificationPayload(type, payload);
|
||||||
|
|
||||||
|
await axios.post(endpoint, notificationPayload);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Error sending Gotify notification', {
|
||||||
|
label: 'Notifications',
|
||||||
|
type: Notification[type],
|
||||||
|
subject: payload.subject,
|
||||||
|
errorMessage: e.message,
|
||||||
|
response: e.response?.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GotifyAgent;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { hasNotificationType, Notification } from '..';
|
import { hasNotificationType, Notification } from '..';
|
||||||
|
import { IssueStatus, IssueType } from '../../../constants/issue';
|
||||||
import { MediaStatus } from '../../../constants/media';
|
import { MediaStatus } from '../../../constants/media';
|
||||||
import logger from '../../../logger';
|
import logger from '../../../logger';
|
||||||
import { getSettings, NotificationAgentLunaSea } from '../../settings';
|
import { getSettings, NotificationAgentLunaSea } from '../../settings';
|
||||||
@@ -7,7 +8,8 @@ import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
|||||||
|
|
||||||
class LunaSeaAgent
|
class LunaSeaAgent
|
||||||
extends BaseAgent<NotificationAgentLunaSea>
|
extends BaseAgent<NotificationAgentLunaSea>
|
||||||
implements NotificationAgent {
|
implements NotificationAgent
|
||||||
|
{
|
||||||
protected getSettings(): NotificationAgentLunaSea {
|
protected getSettings(): NotificationAgentLunaSea {
|
||||||
if (this.settings) {
|
if (this.settings) {
|
||||||
return this.settings;
|
return this.settings;
|
||||||
@@ -21,17 +23,17 @@ class LunaSeaAgent
|
|||||||
private buildPayload(type: Notification, payload: NotificationPayload) {
|
private buildPayload(type: Notification, payload: NotificationPayload) {
|
||||||
return {
|
return {
|
||||||
notification_type: Notification[type],
|
notification_type: Notification[type],
|
||||||
|
event: payload.event,
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
message: payload.message,
|
message: payload.message,
|
||||||
image: payload.image ?? null,
|
image: payload.image ?? null,
|
||||||
email: payload.notifyUser?.email,
|
email: payload.notifyUser?.email,
|
||||||
username: payload.notifyUser?.username,
|
username: payload.notifyUser?.displayName,
|
||||||
avatar: payload.notifyUser?.avatar,
|
avatar: payload.notifyUser?.avatar,
|
||||||
media: payload.media
|
media: payload.media
|
||||||
? {
|
? {
|
||||||
media_type: payload.media.mediaType,
|
media_type: payload.media.mediaType,
|
||||||
tmdbId: payload.media.tmdbId,
|
tmdbId: payload.media.tmdbId,
|
||||||
imdbId: payload.media.imdbId,
|
|
||||||
tvdbId: payload.media.tvdbId,
|
tvdbId: payload.media.tvdbId,
|
||||||
status: MediaStatus[payload.media.status],
|
status: MediaStatus[payload.media.status],
|
||||||
status4k: MediaStatus[payload.media.status4k],
|
status4k: MediaStatus[payload.media.status4k],
|
||||||
@@ -46,6 +48,24 @@ class LunaSeaAgent
|
|||||||
requestedBy_avatar: payload.request.requestedBy.avatar,
|
requestedBy_avatar: payload.request.requestedBy.avatar,
|
||||||
}
|
}
|
||||||
: null,
|
: 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,18 +1,31 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { hasNotificationType, Notification } from '..';
|
import { getRepository } from 'typeorm';
|
||||||
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 logger from '../../../logger';
|
||||||
import { getSettings, NotificationAgentPushbullet } from '../../settings';
|
import {
|
||||||
|
getSettings,
|
||||||
|
NotificationAgentKey,
|
||||||
|
NotificationAgentPushbullet,
|
||||||
|
} from '../../settings';
|
||||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||||
|
|
||||||
interface PushbulletPayload {
|
interface PushbulletPayload {
|
||||||
|
type: string;
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
channel_tag?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class PushbulletAgent
|
class PushbulletAgent
|
||||||
extends BaseAgent<NotificationAgentPushbullet>
|
extends BaseAgent<NotificationAgentPushbullet>
|
||||||
implements NotificationAgent {
|
implements NotificationAgent
|
||||||
|
{
|
||||||
protected getSettings(): NotificationAgentPushbullet {
|
protected getSettings(): NotificationAgentPushbullet {
|
||||||
if (this.settings) {
|
if (this.settings) {
|
||||||
return this.settings;
|
return this.settings;
|
||||||
@@ -24,109 +37,62 @@ class PushbulletAgent
|
|||||||
}
|
}
|
||||||
|
|
||||||
public shouldSend(): boolean {
|
public shouldSend(): boolean {
|
||||||
const settings = this.getSettings();
|
return true;
|
||||||
|
|
||||||
if (settings.enabled && settings.options.accessToken) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private constructMessageDetails(
|
private getNotificationPayload(
|
||||||
type: Notification,
|
type: Notification,
|
||||||
payload: NotificationPayload
|
payload: NotificationPayload
|
||||||
): {
|
): PushbulletPayload {
|
||||||
title: string;
|
const title = payload.event
|
||||||
body: string;
|
? `${payload.event} - ${payload.subject}`
|
||||||
} {
|
: payload.subject;
|
||||||
let messageTitle = '';
|
let body = payload.message ?? '';
|
||||||
let message = '';
|
|
||||||
|
|
||||||
const title = payload.subject;
|
if (payload.request) {
|
||||||
const plot = payload.message;
|
body += `\n\nRequested By: ${payload.request.requestedBy.displayName}`;
|
||||||
const username = payload.request?.requestedBy.displayName;
|
|
||||||
|
|
||||||
switch (type) {
|
let status = '';
|
||||||
case Notification.MEDIA_PENDING:
|
switch (type) {
|
||||||
messageTitle = `New ${
|
case Notification.MEDIA_PENDING:
|
||||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
status = 'Pending Approval';
|
||||||
} Request`;
|
break;
|
||||||
message += `${title}`;
|
case Notification.MEDIA_APPROVED:
|
||||||
if (plot) {
|
case Notification.MEDIA_AUTO_APPROVED:
|
||||||
message += `\n\n${plot}`;
|
status = 'Processing';
|
||||||
}
|
break;
|
||||||
message += `\n\nRequested By: ${username}`;
|
case Notification.MEDIA_AVAILABLE:
|
||||||
message += `\nStatus: Pending Approval`;
|
status = 'Available';
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_APPROVED:
|
case Notification.MEDIA_DECLINED:
|
||||||
messageTitle = `${
|
status = 'Declined';
|
||||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
break;
|
||||||
} Request Approved`;
|
case Notification.MEDIA_FAILED:
|
||||||
message += `${title}`;
|
status = 'Failed';
|
||||||
if (plot) {
|
break;
|
||||||
message += `\n\n${plot}`;
|
}
|
||||||
}
|
|
||||||
message += `\n\nRequested By: ${username}`;
|
if (status) {
|
||||||
message += `\nStatus: Processing`;
|
body += `\nRequest Status: ${status}`;
|
||||||
break;
|
}
|
||||||
case Notification.MEDIA_AUTO_APPROVED:
|
} else if (payload.comment) {
|
||||||
messageTitle = `${
|
body += `\n\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message}`;
|
||||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
} else if (payload.issue) {
|
||||||
} Request Automatically Approved`;
|
body += `\n\nReported By: ${payload.issue.createdBy.displayName}`;
|
||||||
message += `${title}`;
|
body += `\nIssue Type: ${IssueTypeName[payload.issue.issueType]}`;
|
||||||
if (plot) {
|
body += `\nIssue Status: ${
|
||||||
message += `\n\n${plot}`;
|
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
|
||||||
}
|
}`;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const extra of payload.extra ?? []) {
|
for (const extra of payload.extra ?? []) {
|
||||||
message += `\n${extra.name}: ${extra.value}`;
|
body += `\n${extra.name}: ${extra.value}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: messageTitle,
|
type: 'note',
|
||||||
body: message,
|
title,
|
||||||
|
body,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,46 +101,133 @@ class PushbulletAgent
|
|||||||
payload: NotificationPayload
|
payload: NotificationPayload
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const settings = this.getSettings();
|
const settings = this.getSettings();
|
||||||
|
const endpoint = 'https://api.pushbullet.com/v2/pushes';
|
||||||
|
const notificationPayload = this.getNotificationPayload(type, payload);
|
||||||
|
|
||||||
if (!hasNotificationType(type, settings.types ?? 0)) {
|
// Send system notification
|
||||||
return true;
|
if (
|
||||||
}
|
hasNotificationType(type, settings.types ?? 0) &&
|
||||||
|
settings.enabled &&
|
||||||
logger.debug('Sending Pushbullet notification', {
|
settings.options.accessToken
|
||||||
label: 'Notifications',
|
) {
|
||||||
type: Notification[type],
|
logger.debug('Sending Pushbullet notification', {
|
||||||
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', {
|
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
|
||||||
response: e.response?.data,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
try {
|
||||||
|
await axios.post(
|
||||||
|
endpoint,
|
||||||
|
{ ...notificationPayload, channel_tag: settings.options.channelTag },
|
||||||
|
{
|
||||||
|
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 &&
|
||||||
|
(settings.options.channelTag ||
|
||||||
|
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 axios from 'axios';
|
||||||
import { hasNotificationType, Notification } from '..';
|
import { getRepository } from 'typeorm';
|
||||||
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 logger from '../../../logger';
|
||||||
import { getSettings, NotificationAgentPushover } from '../../settings';
|
import {
|
||||||
|
getSettings,
|
||||||
|
NotificationAgentKey,
|
||||||
|
NotificationAgentPushover,
|
||||||
|
} from '../../settings';
|
||||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||||
|
|
||||||
interface PushoverPayload {
|
interface PushoverPayload {
|
||||||
@@ -18,7 +28,8 @@ interface PushoverPayload {
|
|||||||
|
|
||||||
class PushoverAgent
|
class PushoverAgent
|
||||||
extends BaseAgent<NotificationAgentPushover>
|
extends BaseAgent<NotificationAgentPushover>
|
||||||
implements NotificationAgent {
|
implements NotificationAgent
|
||||||
|
{
|
||||||
protected getSettings(): NotificationAgentPushover {
|
protected getSettings(): NotificationAgentPushover {
|
||||||
if (this.settings) {
|
if (this.settings) {
|
||||||
return this.settings;
|
return this.settings;
|
||||||
@@ -30,130 +41,89 @@ class PushoverAgent
|
|||||||
}
|
}
|
||||||
|
|
||||||
public shouldSend(): boolean {
|
public shouldSend(): boolean {
|
||||||
const settings = this.getSettings();
|
return true;
|
||||||
|
|
||||||
if (
|
|
||||||
settings.enabled &&
|
|
||||||
settings.options.accessToken &&
|
|
||||||
settings.options.userToken
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private constructMessageDetails(
|
private getNotificationPayload(
|
||||||
type: Notification,
|
type: Notification,
|
||||||
payload: NotificationPayload
|
payload: NotificationPayload
|
||||||
): {
|
): Partial<PushoverPayload> {
|
||||||
title: string;
|
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||||
message: string;
|
|
||||||
url: string | undefined;
|
const title = payload.event ?? payload.subject;
|
||||||
url_title: string | undefined;
|
let message = payload.event ? `<b>${payload.subject}</b>` : '';
|
||||||
priority: number;
|
|
||||||
} {
|
|
||||||
const settings = getSettings();
|
|
||||||
let messageTitle = '';
|
|
||||||
let message = '';
|
|
||||||
let url: string | undefined;
|
|
||||||
let url_title: string | undefined;
|
|
||||||
let priority = 0;
|
let priority = 0;
|
||||||
|
|
||||||
const title = payload.subject;
|
if (payload.message) {
|
||||||
const plot = payload.message;
|
message += `<small>${message ? '\n' : ''}${payload.message}</small>`;
|
||||||
const username = payload.request?.requestedBy.displayName;
|
}
|
||||||
|
|
||||||
switch (type) {
|
if (payload.request) {
|
||||||
case Notification.MEDIA_PENDING:
|
message += `<small>\n\n<b>Requested By:</b> ${payload.request.requestedBy.displayName}</small>`;
|
||||||
messageTitle = `New ${
|
|
||||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
let status = '';
|
||||||
} Request`;
|
switch (type) {
|
||||||
message += `<b>${title}</b>`;
|
case Notification.MEDIA_PENDING:
|
||||||
if (plot) {
|
status = 'Pending Approval';
|
||||||
message += `<small>\n${plot}</small>`;
|
break;
|
||||||
}
|
case Notification.MEDIA_APPROVED:
|
||||||
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
|
case Notification.MEDIA_AUTO_APPROVED:
|
||||||
message += `<small>\n\n<b>Status</b>\nPending Approval</small>`;
|
status = 'Processing';
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_APPROVED:
|
case Notification.MEDIA_AVAILABLE:
|
||||||
messageTitle = `${
|
status = 'Available';
|
||||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
break;
|
||||||
} Request Approved`;
|
case Notification.MEDIA_DECLINED:
|
||||||
message += `<b>${title}</b>`;
|
status = 'Declined';
|
||||||
if (plot) {
|
priority = 1;
|
||||||
message += `<small>\n${plot}</small>`;
|
break;
|
||||||
}
|
case Notification.MEDIA_FAILED:
|
||||||
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
|
status = 'Failed';
|
||||||
message += `<small>\n\n<b>Status</b>\nProcessing</small>`;
|
priority = 1;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_AUTO_APPROVED:
|
}
|
||||||
messageTitle = `${
|
|
||||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
if (status) {
|
||||||
} Request Automatically Approved`;
|
message += `<small>\n<b>Request Status:</b> ${status}</small>`;
|
||||||
message += `<b>${title}</b>`;
|
}
|
||||||
if (plot) {
|
} else if (payload.comment) {
|
||||||
message += `<small>\n${plot}</small>`;
|
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>Requested By</b>\n${username}</small>`;
|
message += `<small>\n\n<b>Reported By:</b> ${payload.issue.createdBy.displayName}</small>`;
|
||||||
message += `<small>\n\n<b>Status</b>\nProcessing</small>`;
|
message += `<small>\n<b>Issue Type:</b> ${
|
||||||
break;
|
IssueTypeName[payload.issue.issueType]
|
||||||
case Notification.MEDIA_AVAILABLE:
|
}</small>`;
|
||||||
messageTitle = `${
|
message += `<small>\n<b>Issue Status:</b> ${
|
||||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
|
||||||
} Now Available`;
|
}</small>`;
|
||||||
message += `<b>${title}</b>`;
|
|
||||||
if (plot) {
|
if (type === Notification.ISSUE_CREATED) {
|
||||||
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>`;
|
|
||||||
priority = 1;
|
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 ?? []) {
|
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) {
|
const url = applicationUrl
|
||||||
url = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
|
? payload.issue
|
||||||
url_title = `Open in ${settings.main.applicationTitle}`;
|
? `${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 {
|
return {
|
||||||
title: messageTitle,
|
title,
|
||||||
message,
|
message,
|
||||||
url,
|
url,
|
||||||
url_title,
|
url_title,
|
||||||
priority,
|
priority,
|
||||||
|
html: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,50 +132,134 @@ class PushoverAgent
|
|||||||
payload: NotificationPayload
|
payload: NotificationPayload
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const settings = this.getSettings();
|
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)) {
|
// Send system notification
|
||||||
return true;
|
if (
|
||||||
}
|
hasNotificationType(type, settings.types ?? 0) &&
|
||||||
|
settings.enabled &&
|
||||||
logger.debug('Sending Pushover notification', {
|
settings.options.accessToken &&
|
||||||
label: 'Notifications',
|
settings.options.userToken
|
||||||
type: Notification[type],
|
) {
|
||||||
subject: payload.subject,
|
logger.debug('Sending Pushover notification', {
|
||||||
});
|
|
||||||
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', {
|
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
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 axios from 'axios';
|
||||||
import { hasNotificationType, Notification } from '..';
|
import { hasNotificationType, Notification } from '..';
|
||||||
import { MediaType } from '../../../constants/media';
|
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||||
import logger from '../../../logger';
|
import logger from '../../../logger';
|
||||||
import { getSettings, NotificationAgentSlack } from '../../settings';
|
import { getSettings, NotificationAgentSlack } from '../../settings';
|
||||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||||
@@ -19,9 +19,10 @@ interface TextItem {
|
|||||||
interface Element {
|
interface Element {
|
||||||
type: 'button';
|
type: 'button';
|
||||||
text?: TextItem;
|
text?: TextItem;
|
||||||
value: string;
|
action_id: string;
|
||||||
url: string;
|
url?: string;
|
||||||
action_id: 'button-action';
|
value?: string;
|
||||||
|
style?: 'primary' | 'danger';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EmbedBlock {
|
interface EmbedBlock {
|
||||||
@@ -34,10 +35,11 @@ interface EmbedBlock {
|
|||||||
image_url: string;
|
image_url: string;
|
||||||
alt_text: string;
|
alt_text: string;
|
||||||
};
|
};
|
||||||
elements?: Element[];
|
elements?: (Element | TextItem)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SlackBlockEmbed {
|
interface SlackBlockEmbed {
|
||||||
|
text: string;
|
||||||
blocks: EmbedBlock[];
|
blocks: EmbedBlock[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,9 +61,7 @@ class SlackAgent
|
|||||||
type: Notification,
|
type: Notification,
|
||||||
payload: NotificationPayload
|
payload: NotificationPayload
|
||||||
): SlackBlockEmbed {
|
): SlackBlockEmbed {
|
||||||
const settings = getSettings();
|
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||||
let header = '';
|
|
||||||
let actionUrl: string | undefined;
|
|
||||||
|
|
||||||
const fields: EmbedField[] = [];
|
const fields: EmbedField[] = [];
|
||||||
|
|
||||||
@@ -70,66 +70,55 @@ class SlackAgent
|
|||||||
type: 'mrkdwn',
|
type: 'mrkdwn',
|
||||||
text: `*Requested By*\n${payload.request.requestedBy.displayName}`,
|
text: `*Requested By*\n${payload.request.requestedBy.displayName}`,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
switch (type) {
|
let status = '';
|
||||||
case Notification.MEDIA_PENDING:
|
switch (type) {
|
||||||
header = `New ${
|
case Notification.MEDIA_PENDING:
|
||||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
status = 'Pending Approval';
|
||||||
} Request`;
|
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({
|
fields.push({
|
||||||
type: 'mrkdwn',
|
type: 'mrkdwn',
|
||||||
text: '*Status*\nPending Approval',
|
text: `*Request Status*\n${status}`,
|
||||||
});
|
});
|
||||||
break;
|
}
|
||||||
case Notification.MEDIA_APPROVED:
|
} else if (payload.comment) {
|
||||||
header = `${
|
fields.push({
|
||||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
type: 'mrkdwn',
|
||||||
} Request Approved`;
|
text: `*Comment from ${payload.comment.user.displayName}*\n${payload.comment.message}`,
|
||||||
fields.push({
|
});
|
||||||
|
} else if (payload.issue) {
|
||||||
|
fields.push(
|
||||||
|
{
|
||||||
type: 'mrkdwn',
|
type: 'mrkdwn',
|
||||||
text: '*Status*\nProcessing',
|
text: `*Reported By*\n${payload.issue.createdBy.displayName}`,
|
||||||
});
|
},
|
||||||
break;
|
{
|
||||||
case Notification.MEDIA_AUTO_APPROVED:
|
|
||||||
header = `${
|
|
||||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
|
||||||
} Request Automatically Approved`;
|
|
||||||
fields.push({
|
|
||||||
type: 'mrkdwn',
|
type: 'mrkdwn',
|
||||||
text: '*Status*\nProcessing',
|
text: `*Issue Type*\n${IssueTypeName[payload.issue.issueType]}`,
|
||||||
});
|
},
|
||||||
break;
|
{
|
||||||
case Notification.MEDIA_AVAILABLE:
|
|
||||||
header = `${
|
|
||||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
|
||||||
} Now Available`;
|
|
||||||
fields.push({
|
|
||||||
type: 'mrkdwn',
|
type: 'mrkdwn',
|
||||||
text: '*Status*\nAvailable',
|
text: `*Issue Status*\n${
|
||||||
});
|
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const extra of payload.extra ?? []) {
|
for (const extra of payload.extra ?? []) {
|
||||||
@@ -139,30 +128,28 @@ class SlackAgent
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.main.applicationUrl && payload.media) {
|
const blocks: EmbedBlock[] = [];
|
||||||
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blocks: EmbedBlock[] = [
|
if (payload.event) {
|
||||||
{
|
|
||||||
type: 'header',
|
|
||||||
text: {
|
|
||||||
type: 'plain_text',
|
|
||||||
text: header,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (type !== Notification.TEST_NOTIFICATION) {
|
|
||||||
blocks.push({
|
blocks.push({
|
||||||
type: 'section',
|
type: 'context',
|
||||||
text: {
|
elements: [
|
||||||
type: 'mrkdwn',
|
{
|
||||||
text: `*${payload.subject}*`,
|
type: 'mrkdwn',
|
||||||
},
|
text: `*${payload.event}*`,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
blocks.push({
|
||||||
|
type: 'header',
|
||||||
|
text: {
|
||||||
|
type: 'plain_text',
|
||||||
|
text: payload.subject,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (payload.message) {
|
if (payload.message) {
|
||||||
blocks.push({
|
blocks.push({
|
||||||
type: 'section',
|
type: 'section',
|
||||||
@@ -183,30 +170,31 @@ class SlackAgent
|
|||||||
if (fields.length > 0) {
|
if (fields.length > 0) {
|
||||||
blocks.push({
|
blocks.push({
|
||||||
type: 'section',
|
type: 'section',
|
||||||
fields: [
|
fields,
|
||||||
...fields,
|
|
||||||
...(payload.extra ?? []).map(
|
|
||||||
(extra): EmbedField => ({
|
|
||||||
type: 'mrkdwn',
|
|
||||||
text: `*${extra.name}*\n${extra.value}`,
|
|
||||||
})
|
|
||||||
),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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({
|
blocks.push({
|
||||||
type: 'actions',
|
type: 'actions',
|
||||||
elements: [
|
elements: [
|
||||||
{
|
{
|
||||||
action_id: 'button-action',
|
action_id: 'open-in-overseerr',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
url: actionUrl,
|
url,
|
||||||
value: 'open_jellyseerr',
|
|
||||||
text: {
|
text: {
|
||||||
type: 'plain_text',
|
type: 'plain_text',
|
||||||
text: `Open in ${settings.main.applicationTitle}`,
|
text: `View ${
|
||||||
|
payload.issue ? 'Issue' : 'Media'
|
||||||
|
} in ${applicationTitle}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -214,6 +202,7 @@ class SlackAgent
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
text: payload.event ?? payload.subject,
|
||||||
blocks,
|
blocks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { getRepository } from 'typeorm';
|
import { getRepository } from 'typeorm';
|
||||||
import { hasNotificationType, Notification } from '..';
|
import {
|
||||||
import { MediaType } from '../../../constants/media';
|
hasNotificationType,
|
||||||
|
Notification,
|
||||||
|
shouldSendAdminNotification,
|
||||||
|
} from '..';
|
||||||
|
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||||
import { User } from '../../../entity/User';
|
import { User } from '../../../entity/User';
|
||||||
import logger from '../../../logger';
|
import logger from '../../../logger';
|
||||||
import { Permission } from '../../permissions';
|
|
||||||
import {
|
import {
|
||||||
getSettings,
|
getSettings,
|
||||||
NotificationAgentKey,
|
NotificationAgentKey,
|
||||||
@@ -29,7 +32,8 @@ interface TelegramPhotoPayload {
|
|||||||
|
|
||||||
class TelegramAgent
|
class TelegramAgent
|
||||||
extends BaseAgent<NotificationAgentTelegram>
|
extends BaseAgent<NotificationAgentTelegram>
|
||||||
implements NotificationAgent {
|
implements NotificationAgent
|
||||||
|
{
|
||||||
private baseUrl = 'https://api.telegram.org/';
|
private baseUrl = 'https://api.telegram.org/';
|
||||||
|
|
||||||
protected getSettings(): NotificationAgentTelegram {
|
protected getSettings(): NotificationAgentTelegram {
|
||||||
@@ -45,11 +49,7 @@ class TelegramAgent
|
|||||||
public shouldSend(): boolean {
|
public shouldSend(): boolean {
|
||||||
const settings = this.getSettings();
|
const settings = this.getSettings();
|
||||||
|
|
||||||
if (
|
if (settings.enabled && settings.options.botAPI) {
|
||||||
settings.enabled &&
|
|
||||||
settings.options.botAPI &&
|
|
||||||
settings.options.chatId
|
|
||||||
) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,118 +60,91 @@ class TelegramAgent
|
|||||||
return text ? text.replace(/[_*[\]()~>#+=|{}.!-]/gi, (x) => '\\' + x) : '';
|
return text ? text.replace(/[_*[\]()~>#+=|{}.!-]/gi, (x) => '\\' + x) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildMessage(
|
private getNotificationPayload(
|
||||||
type: Notification,
|
type: Notification,
|
||||||
payload: NotificationPayload,
|
payload: NotificationPayload
|
||||||
chatId: string,
|
): Partial<TelegramMessagePayload | TelegramPhotoPayload> {
|
||||||
sendSilently: boolean
|
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||||
): 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);
|
|
||||||
|
|
||||||
/* eslint-disable no-useless-escape */
|
/* eslint-disable no-useless-escape */
|
||||||
switch (type) {
|
let message = `\*${this.escapeText(
|
||||||
case Notification.MEDIA_PENDING:
|
payload.event ? `${payload.event} - ${payload.subject}` : payload.subject
|
||||||
message += `\*New ${
|
)}\*`;
|
||||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
if (payload.message) {
|
||||||
} Request\*`;
|
message += `\n${this.escapeText(payload.message)}`;
|
||||||
message += `\n\n\*${title}\*`;
|
}
|
||||||
if (plot) {
|
|
||||||
message += `\n${plot}`;
|
if (payload.request) {
|
||||||
}
|
message += `\n\n\*Requested By:\* ${this.escapeText(
|
||||||
message += `\n\n\*Requested By\*\n${user}`;
|
payload.request?.requestedBy.displayName
|
||||||
message += `\n\n\*Status\*\nPending Approval`;
|
)}`;
|
||||||
break;
|
|
||||||
case Notification.MEDIA_APPROVED:
|
let status = '';
|
||||||
message += `\*${
|
switch (type) {
|
||||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
case Notification.MEDIA_PENDING:
|
||||||
} Request Approved\*`;
|
status = 'Pending Approval';
|
||||||
message += `\n\n\*${title}\*`;
|
break;
|
||||||
if (plot) {
|
case Notification.MEDIA_APPROVED:
|
||||||
message += `\n${plot}`;
|
case Notification.MEDIA_AUTO_APPROVED:
|
||||||
}
|
status = 'Processing';
|
||||||
message += `\n\n\*Requested By\*\n${user}`;
|
break;
|
||||||
message += `\n\n\*Status\*\nProcessing`;
|
case Notification.MEDIA_AVAILABLE:
|
||||||
break;
|
status = 'Available';
|
||||||
case Notification.MEDIA_AUTO_APPROVED:
|
break;
|
||||||
message += `\*${
|
case Notification.MEDIA_DECLINED:
|
||||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
status = 'Declined';
|
||||||
} Request Automatically Approved\*`;
|
break;
|
||||||
message += `\n\n\*${title}\*`;
|
case Notification.MEDIA_FAILED:
|
||||||
if (plot) {
|
status = 'Failed';
|
||||||
message += `\n${plot}`;
|
break;
|
||||||
}
|
}
|
||||||
message += `\n\n\*Requested By\*\n${user}`;
|
|
||||||
message += `\n\n\*Status\*\nProcessing`;
|
if (status) {
|
||||||
break;
|
message += `\n\*Request Status:\* ${status}`;
|
||||||
case Notification.MEDIA_AVAILABLE:
|
}
|
||||||
message += `\*${
|
} else if (payload.comment) {
|
||||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
message += `\n\n\*Comment from ${this.escapeText(
|
||||||
} Now Available\*`;
|
payload.comment.user.displayName
|
||||||
message += `\n\n\*${title}\*`;
|
)}:\* ${this.escapeText(payload.comment.message)}`;
|
||||||
if (plot) {
|
} else if (payload.issue) {
|
||||||
message += `\n${plot}`;
|
message += `\n\n\*Reported By:\* ${this.escapeText(
|
||||||
}
|
payload.issue.createdBy.displayName
|
||||||
message += `\n\n\*Requested By\*\n${user}`;
|
)}`;
|
||||||
message += `\n\n\*Status\*\nAvailable`;
|
message += `\n\*Issue Type:\* ${IssueTypeName[payload.issue.issueType]}`;
|
||||||
break;
|
message += `\n\*Issue Status:\* ${
|
||||||
case Notification.MEDIA_DECLINED:
|
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const extra of payload.extra ?? []) {
|
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 url = applicationUrl
|
||||||
const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
|
? payload.issue
|
||||||
message += `\n\n\[Open in ${applicationTitle}\]\(${actionUrl}\)`;
|
? `${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 */
|
/* eslint-enable */
|
||||||
|
|
||||||
return payload.image
|
return payload.image
|
||||||
? ({
|
? {
|
||||||
photo: payload.image,
|
photo: payload.image,
|
||||||
caption: message,
|
caption: message,
|
||||||
parse_mode: 'MarkdownV2',
|
parse_mode: 'MarkdownV2',
|
||||||
chat_id: chatId,
|
}
|
||||||
disable_notification: !!sendSilently,
|
: {
|
||||||
} as TelegramPhotoPayload)
|
|
||||||
: ({
|
|
||||||
text: message,
|
text: message,
|
||||||
parse_mode: 'MarkdownV2',
|
parse_mode: 'MarkdownV2',
|
||||||
chat_id: chatId,
|
};
|
||||||
disable_notification: !!sendSilently,
|
|
||||||
} as TelegramMessagePayload);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async send(
|
public async send(
|
||||||
@@ -179,13 +152,16 @@ class TelegramAgent
|
|||||||
payload: NotificationPayload
|
payload: NotificationPayload
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const settings = this.getSettings();
|
const settings = this.getSettings();
|
||||||
|
|
||||||
const endpoint = `${this.baseUrl}bot${settings.options.botAPI}/${
|
const endpoint = `${this.baseUrl}bot${settings.options.botAPI}/${
|
||||||
payload.image ? 'sendPhoto' : 'sendMessage'
|
payload.image ? 'sendPhoto' : 'sendMessage'
|
||||||
}`;
|
}`;
|
||||||
|
const notificationPayload = this.getNotificationPayload(type, payload);
|
||||||
|
|
||||||
// Send system notification
|
// Send system notification
|
||||||
if (hasNotificationType(type, settings.types ?? 0)) {
|
if (
|
||||||
|
hasNotificationType(type, settings.types ?? 0) &&
|
||||||
|
settings.options.chatId
|
||||||
|
) {
|
||||||
logger.debug('Sending Telegram notification', {
|
logger.debug('Sending Telegram notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
@@ -193,15 +169,11 @@ class TelegramAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(
|
await axios.post(endpoint, {
|
||||||
endpoint,
|
...notificationPayload,
|
||||||
this.buildMessage(
|
chat_id: settings.options.chatId,
|
||||||
type,
|
disable_notification: !!settings.options.sendSilently,
|
||||||
payload,
|
} as TelegramMessagePayload | TelegramPhotoPayload);
|
||||||
settings.options.chatId,
|
|
||||||
settings.options.sendSilently
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error sending Telegram notification', {
|
logger.error('Error sending Telegram notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
@@ -216,14 +188,13 @@ class TelegramAgent
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload.notifyUser) {
|
if (payload.notifyUser) {
|
||||||
// Send notification to the user who submitted the request
|
|
||||||
if (
|
if (
|
||||||
payload.notifyUser.settings?.hasNotificationType(
|
payload.notifyUser.settings?.hasNotificationType(
|
||||||
NotificationAgentKey.TELEGRAM,
|
NotificationAgentKey.TELEGRAM,
|
||||||
type
|
type
|
||||||
) &&
|
) &&
|
||||||
payload.notifyUser.settings?.telegramChatId &&
|
payload.notifyUser.settings?.telegramChatId &&
|
||||||
payload.notifyUser.settings?.telegramChatId !== settings.options.chatId
|
payload.notifyUser.settings.telegramChatId !== settings.options.chatId
|
||||||
) {
|
) {
|
||||||
logger.debug('Sending Telegram notification', {
|
logger.debug('Sending Telegram notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
@@ -233,15 +204,12 @@ class TelegramAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(
|
await axios.post(endpoint, {
|
||||||
endpoint,
|
...notificationPayload,
|
||||||
this.buildMessage(
|
chat_id: payload.notifyUser.settings.telegramChatId,
|
||||||
type,
|
disable_notification:
|
||||||
payload,
|
!!payload.notifyUser.settings.telegramSendSilently,
|
||||||
payload.notifyUser.settings.telegramChatId,
|
} as TelegramMessagePayload | TelegramPhotoPayload);
|
||||||
!!payload.notifyUser.settings.telegramSendSilently
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error sending Telegram notification', {
|
logger.error('Error sending Telegram notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
@@ -255,8 +223,9 @@ class TelegramAgent
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
// Send notifications to all users with the Manage Requests permission
|
|
||||||
|
if (payload.notifyAdmin) {
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
const users = await userRepository.find();
|
const users = await userRepository.find();
|
||||||
|
|
||||||
@@ -264,14 +233,10 @@ class TelegramAgent
|
|||||||
users
|
users
|
||||||
.filter(
|
.filter(
|
||||||
(user) =>
|
(user) =>
|
||||||
user.hasPermission(Permission.MANAGE_REQUESTS) &&
|
|
||||||
user.settings?.hasNotificationType(
|
user.settings?.hasNotificationType(
|
||||||
NotificationAgentKey.TELEGRAM,
|
NotificationAgentKey.TELEGRAM,
|
||||||
type
|
type
|
||||||
) &&
|
) && shouldSendAdminNotification(type, user, payload)
|
||||||
// Check if it's the user's own auto-approved request
|
|
||||||
(type !== Notification.MEDIA_AUTO_APPROVED ||
|
|
||||||
user.id !== payload.request?.requestedBy.id)
|
|
||||||
)
|
)
|
||||||
.map(async (user) => {
|
.map(async (user) => {
|
||||||
if (
|
if (
|
||||||
@@ -286,15 +251,11 @@ class TelegramAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(
|
await axios.post(endpoint, {
|
||||||
endpoint,
|
...notificationPayload,
|
||||||
this.buildMessage(
|
chat_id: user.settings.telegramChatId,
|
||||||
type,
|
disable_notification: !!user.settings?.telegramSendSilently,
|
||||||
payload,
|
} as TelegramMessagePayload | TelegramPhotoPayload);
|
||||||
user.settings.telegramChatId,
|
|
||||||
!!user.settings?.telegramSendSilently
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error sending Telegram notification', {
|
logger.error('Error sending Telegram notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { get } from 'lodash';
|
import { get } from 'lodash';
|
||||||
import { hasNotificationType, Notification } from '..';
|
import { hasNotificationType, Notification } from '..';
|
||||||
|
import { IssueStatus, IssueType } from '../../../constants/issue';
|
||||||
import { MediaStatus } from '../../../constants/media';
|
import { MediaStatus } from '../../../constants/media';
|
||||||
import logger from '../../../logger';
|
import logger from '../../../logger';
|
||||||
import { getSettings, NotificationAgentWebhook } from '../../settings';
|
import { getSettings, NotificationAgentWebhook } from '../../settings';
|
||||||
@@ -13,6 +14,7 @@ type KeyMapFunction = (
|
|||||||
|
|
||||||
const KeyMap: Record<string, string | KeyMapFunction> = {
|
const KeyMap: Record<string, string | KeyMapFunction> = {
|
||||||
notification_type: (_payload, type) => Notification[type],
|
notification_type: (_payload, type) => Notification[type],
|
||||||
|
event: 'event',
|
||||||
subject: 'subject',
|
subject: 'subject',
|
||||||
message: 'message',
|
message: 'message',
|
||||||
image: 'image',
|
image: 'image',
|
||||||
@@ -22,13 +24,12 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
|
|||||||
notifyuser_settings_discordId: 'notifyUser.settings.discordId',
|
notifyuser_settings_discordId: 'notifyUser.settings.discordId',
|
||||||
notifyuser_settings_telegramChatId: 'notifyUser.settings.telegramChatId',
|
notifyuser_settings_telegramChatId: 'notifyUser.settings.telegramChatId',
|
||||||
media_tmdbid: 'media.tmdbId',
|
media_tmdbid: 'media.tmdbId',
|
||||||
media_imdbid: 'media.imdbId',
|
|
||||||
media_tvdbid: 'media.tvdbId',
|
media_tvdbid: 'media.tvdbId',
|
||||||
media_type: 'media.mediaType',
|
media_type: 'media.mediaType',
|
||||||
media_status: (payload) =>
|
media_status: (payload) =>
|
||||||
payload.media?.status ? MediaStatus[payload.media?.status] : '',
|
payload.media ? MediaStatus[payload.media.status] : '',
|
||||||
media_status4k: (payload) =>
|
media_status4k: (payload) =>
|
||||||
payload.media?.status ? MediaStatus[payload.media?.status4k] : '',
|
payload.media ? MediaStatus[payload.media.status4k] : '',
|
||||||
request_id: 'request.id',
|
request_id: 'request.id',
|
||||||
requestedBy_username: 'request.requestedBy.displayName',
|
requestedBy_username: 'request.requestedBy.displayName',
|
||||||
requestedBy_email: 'request.requestedBy.email',
|
requestedBy_email: 'request.requestedBy.email',
|
||||||
@@ -36,11 +37,28 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
|
|||||||
requestedBy_settings_discordId: 'request.requestedBy.settings.discordId',
|
requestedBy_settings_discordId: 'request.requestedBy.settings.discordId',
|
||||||
requestedBy_settings_telegramChatId:
|
requestedBy_settings_telegramChatId:
|
||||||
'request.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
|
class WebhookAgent
|
||||||
extends BaseAgent<NotificationAgentWebhook>
|
extends BaseAgent<NotificationAgentWebhook>
|
||||||
implements NotificationAgent {
|
implements NotificationAgent
|
||||||
|
{
|
||||||
protected getSettings(): NotificationAgentWebhook {
|
protected getSettings(): NotificationAgentWebhook {
|
||||||
if (this.settings) {
|
if (this.settings) {
|
||||||
return this.settings;
|
return this.settings;
|
||||||
@@ -77,6 +95,22 @@ class WebhookAgent
|
|||||||
}
|
}
|
||||||
delete finalPayload[key];
|
delete finalPayload[key];
|
||||||
key = 'request';
|
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') {
|
if (typeof finalPayload[key] === 'string') {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { getRepository } from 'typeorm';
|
import { getRepository } from 'typeorm';
|
||||||
import webpush from 'web-push';
|
import webpush from 'web-push';
|
||||||
import { Notification } from '..';
|
import { Notification, shouldSendAdminNotification } from '..';
|
||||||
|
import { IssueType, IssueTypeName } from '../../../constants/issue';
|
||||||
import { MediaType } from '../../../constants/media';
|
import { MediaType } from '../../../constants/media';
|
||||||
import { User } from '../../../entity/User';
|
import { User } from '../../../entity/User';
|
||||||
import { UserPushSubscription } from '../../../entity/UserPushSubscription';
|
import { UserPushSubscription } from '../../../entity/UserPushSubscription';
|
||||||
import logger from '../../../logger';
|
import logger from '../../../logger';
|
||||||
import { Permission } from '../../permissions';
|
|
||||||
import {
|
import {
|
||||||
getSettings,
|
getSettings,
|
||||||
NotificationAgentConfig,
|
NotificationAgentConfig,
|
||||||
@@ -15,18 +15,18 @@ import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
|||||||
|
|
||||||
interface PushNotificationPayload {
|
interface PushNotificationPayload {
|
||||||
notificationType: string;
|
notificationType: string;
|
||||||
mediaType?: 'movie' | 'tv';
|
|
||||||
tmdbId?: number;
|
|
||||||
subject: string;
|
subject: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
actionUrl?: string;
|
actionUrl?: string;
|
||||||
|
actionUrlTitle?: string;
|
||||||
requestId?: number;
|
requestId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
class WebPushAgent
|
class WebPushAgent
|
||||||
extends BaseAgent<NotificationAgentConfig>
|
extends BaseAgent<NotificationAgentConfig>
|
||||||
implements NotificationAgent {
|
implements NotificationAgent
|
||||||
|
{
|
||||||
protected getSettings(): NotificationAgentConfig {
|
protected getSettings(): NotificationAgentConfig {
|
||||||
if (this.settings) {
|
if (this.settings) {
|
||||||
return this.settings;
|
return this.settings;
|
||||||
@@ -41,97 +41,92 @@ class WebPushAgent
|
|||||||
type: Notification,
|
type: Notification,
|
||||||
payload: NotificationPayload
|
payload: NotificationPayload
|
||||||
): PushNotificationPayload {
|
): 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) {
|
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 {
|
return {
|
||||||
notificationType: Notification[type],
|
notificationType: Notification[type],
|
||||||
subject: 'Unknown',
|
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 {
|
public shouldSend(): boolean {
|
||||||
@@ -150,7 +145,7 @@ class WebPushAgent
|
|||||||
const userPushSubRepository = getRepository(UserPushSubscription);
|
const userPushSubRepository = getRepository(UserPushSubscription);
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
let pushSubs: UserPushSubscription[] = [];
|
const pushSubs: UserPushSubscription[] = [];
|
||||||
|
|
||||||
const mainUser = await userRepository.findOne({ where: { id: 1 } });
|
const mainUser = await userRepository.findOne({ where: { id: 1 } });
|
||||||
|
|
||||||
@@ -168,13 +163,14 @@ class WebPushAgent
|
|||||||
where: { user: payload.notifyUser.id },
|
where: { user: payload.notifyUser.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
pushSubs = notifySubs;
|
pushSubs.push(...notifySubs);
|
||||||
} else if (!payload.notifyUser) {
|
}
|
||||||
|
|
||||||
|
if (payload.notifyAdmin) {
|
||||||
const users = await userRepository.find();
|
const users = await userRepository.find();
|
||||||
|
|
||||||
const manageUsers = users.filter(
|
const manageUsers = users.filter(
|
||||||
(user) =>
|
(user) =>
|
||||||
user.hasPermission(Permission.MANAGE_REQUESTS) &&
|
|
||||||
// Check if user has webpush notifications enabled and fallback to true if undefined
|
// Check if user has webpush notifications enabled and fallback to true if undefined
|
||||||
// since web push should default to true
|
// since web push should default to true
|
||||||
(user.settings?.hasNotificationType(
|
(user.settings?.hasNotificationType(
|
||||||
@@ -182,9 +178,7 @@ class WebPushAgent
|
|||||||
type
|
type
|
||||||
) ??
|
) ??
|
||||||
true) &&
|
true) &&
|
||||||
// Check if it's the user's own auto-approved request
|
shouldSendAdminNotification(type, user, payload)
|
||||||
(type !== Notification.MEDIA_AUTO_APPROVED ||
|
|
||||||
user.id !== payload.request?.requestedBy.id)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const allSubs = await userPushSubRepository
|
const allSubs = await userPushSubRepository
|
||||||
@@ -195,7 +189,7 @@ class WebPushAgent
|
|||||||
})
|
})
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|
||||||
pushSubs = allSubs;
|
pushSubs.push(...allSubs);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mainUser && pushSubs.length > 0) {
|
if (mainUser && pushSubs.length > 0) {
|
||||||
@@ -205,6 +199,11 @@ class WebPushAgent
|
|||||||
settings.vapidPrivate
|
settings.vapidPrivate
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const notificationPayload = Buffer.from(
|
||||||
|
JSON.stringify(this.getNotificationPayload(type, payload)),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
pushSubs.map(async (sub) => {
|
pushSubs.map(async (sub) => {
|
||||||
logger.debug('Sending web push notification', {
|
logger.debug('Sending web push notification', {
|
||||||
@@ -223,10 +222,7 @@ class WebPushAgent
|
|||||||
p256dh: sub.p256dh,
|
p256dh: sub.p256dh,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Buffer.from(
|
notificationPayload
|
||||||
JSON.stringify(this.getNotificationPayload(type, payload)),
|
|
||||||
'utf-8'
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { User } from '../../entity/User';
|
||||||
import logger from '../../logger';
|
import logger from '../../logger';
|
||||||
|
import { Permission } from '../permissions';
|
||||||
import type { NotificationAgent, NotificationPayload } from './agents/agent';
|
import type { NotificationAgent, NotificationPayload } from './agents/agent';
|
||||||
|
|
||||||
export enum Notification {
|
export enum Notification {
|
||||||
@@ -10,6 +12,10 @@ export enum Notification {
|
|||||||
TEST_NOTIFICATION = 32,
|
TEST_NOTIFICATION = 32,
|
||||||
MEDIA_DECLINED = 64,
|
MEDIA_DECLINED = 64,
|
||||||
MEDIA_AUTO_APPROVED = 128,
|
MEDIA_AUTO_APPROVED = 128,
|
||||||
|
ISSUE_CREATED = 256,
|
||||||
|
ISSUE_COMMENT = 512,
|
||||||
|
ISSUE_RESOLVED = 1024,
|
||||||
|
ISSUE_REOPENED = 2048,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const hasNotificationType = (
|
export const hasNotificationType = (
|
||||||
@@ -38,6 +44,50 @@ export const hasNotificationType = (
|
|||||||
return !!(value & total);
|
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 {
|
class NotificationManager {
|
||||||
private activeAgents: NotificationAgent[] = [];
|
private activeAgents: NotificationAgent[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export enum Permission {
|
|||||||
AUTO_APPROVE_4K_TV = 131072,
|
AUTO_APPROVE_4K_TV = 131072,
|
||||||
REQUEST_MOVIE = 262144,
|
REQUEST_MOVIE = 262144,
|
||||||
REQUEST_TV = 524288,
|
REQUEST_TV = 524288,
|
||||||
|
MANAGE_ISSUES = 1048576,
|
||||||
|
VIEW_ISSUES = 2097152,
|
||||||
|
CREATE_ISSUES = 4194304,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PermissionCheckOptions {
|
export interface PermissionCheckOptions {
|
||||||
|
|||||||
@@ -146,9 +146,8 @@ class BaseScanner<T> {
|
|||||||
existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] !==
|
existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] !==
|
||||||
externalServiceId
|
externalServiceId
|
||||||
) {
|
) {
|
||||||
existing[
|
existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||||
is4k ? 'externalServiceId4k' : 'externalServiceId'
|
externalServiceId;
|
||||||
] = externalServiceId;
|
|
||||||
changedExisting = true;
|
changedExisting = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,9 +156,8 @@ class BaseScanner<T> {
|
|||||||
existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
|
existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
|
||||||
externalServiceSlug
|
externalServiceSlug
|
||||||
) {
|
) {
|
||||||
existing[
|
existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
||||||
is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
|
externalServiceSlug;
|
||||||
] = externalServiceSlug;
|
|
||||||
changedExisting = true;
|
changedExisting = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,15 +388,13 @@ class BaseScanner<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (externalServiceId !== undefined) {
|
if (externalServiceId !== undefined) {
|
||||||
media[
|
media[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||||
is4k ? 'externalServiceId4k' : 'externalServiceId'
|
externalServiceId;
|
||||||
] = externalServiceId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (externalServiceSlug !== undefined) {
|
if (externalServiceSlug !== undefined) {
|
||||||
media[
|
media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
||||||
is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
|
externalServiceSlug;
|
||||||
] = externalServiceSlug;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the show is already available, and there are no new seasons, dont adjust
|
// If the show is already available, and there are no new seasons, dont adjust
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ type SyncStatus = StatusBase & {
|
|||||||
|
|
||||||
class PlexScanner
|
class PlexScanner
|
||||||
extends BaseScanner<PlexLibraryItem>
|
extends BaseScanner<PlexLibraryItem>
|
||||||
implements RunnableScanner<SyncStatus> {
|
implements RunnableScanner<SyncStatus>
|
||||||
|
{
|
||||||
private plexClient: PlexAPI;
|
private plexClient: PlexAPI;
|
||||||
private libraries: Library[];
|
private libraries: Library[];
|
||||||
private currentLibrary: Library;
|
private currentLibrary: Library;
|
||||||
@@ -370,10 +371,10 @@ class PlexScanner
|
|||||||
|
|
||||||
// If we got an IMDb ID, but no TMDb ID, lookup the TMDb ID with the IMDb ID
|
// If we got an IMDb ID, but no TMDb ID, lookup the TMDb ID with the IMDb ID
|
||||||
if (mediaIds.imdbId && !mediaIds.tmdbId) {
|
if (mediaIds.imdbId && !mediaIds.tmdbId) {
|
||||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
const tmdbMedia = await this.tmdb.getMediaByImdbId({
|
||||||
imdbId: mediaIds.imdbId,
|
imdbId: mediaIds.imdbId,
|
||||||
});
|
});
|
||||||
mediaIds.tmdbId = tmdbMovie.id;
|
mediaIds.tmdbId = tmdbMedia.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache GUIDs
|
// Cache GUIDs
|
||||||
@@ -384,10 +385,10 @@ class PlexScanner
|
|||||||
const imdbMatch = plexitem.guid.match(imdbRegex);
|
const imdbMatch = plexitem.guid.match(imdbRegex);
|
||||||
if (imdbMatch) {
|
if (imdbMatch) {
|
||||||
mediaIds.imdbId = imdbMatch[1];
|
mediaIds.imdbId = imdbMatch[1];
|
||||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
const tmdbMedia = await this.tmdb.getMediaByImdbId({
|
||||||
imdbId: mediaIds.imdbId,
|
imdbId: mediaIds.imdbId,
|
||||||
});
|
});
|
||||||
mediaIds.tmdbId = tmdbMovie.id;
|
mediaIds.tmdbId = tmdbMedia.id;
|
||||||
}
|
}
|
||||||
// Check if the agent is TMDb
|
// Check if the agent is TMDb
|
||||||
} else if (plexitem.guid.match(tmdbRegex)) {
|
} else if (plexitem.guid.match(tmdbRegex)) {
|
||||||
@@ -472,7 +473,7 @@ class PlexScanner
|
|||||||
mediaIds.tmdbId = result.tmdbId;
|
mediaIds.tmdbId = result.tmdbId;
|
||||||
mediaIds.imdbId = result?.imdbId;
|
mediaIds.imdbId = result?.imdbId;
|
||||||
} else if (result?.imdbId) {
|
} else if (result?.imdbId) {
|
||||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
const tmdbMovie = await this.tmdb.getMediaByImdbId({
|
||||||
imdbId: result.imdbId,
|
imdbId: result.imdbId,
|
||||||
});
|
});
|
||||||
mediaIds.tmdbId = tmdbMovie.id;
|
mediaIds.tmdbId = tmdbMovie.id;
|
||||||
@@ -521,7 +522,7 @@ class PlexScanner
|
|||||||
if (special.tmdbId) {
|
if (special.tmdbId) {
|
||||||
await this.processPlexMovieByTmdbId(episode, special.tmdbId);
|
await this.processPlexMovieByTmdbId(episode, special.tmdbId);
|
||||||
} else if (special.imdbId) {
|
} else if (special.imdbId) {
|
||||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
const tmdbMovie = await this.tmdb.getMediaByImdbId({
|
||||||
imdbId: special.imdbId,
|
imdbId: special.imdbId,
|
||||||
});
|
});
|
||||||
await this.processPlexMovieByTmdbId(episode, tmdbMovie.id);
|
await this.processPlexMovieByTmdbId(episode, tmdbMovie.id);
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ type SyncStatus = StatusBase & {
|
|||||||
|
|
||||||
class RadarrScanner
|
class RadarrScanner
|
||||||
extends BaseScanner<RadarrMovie>
|
extends BaseScanner<RadarrMovie>
|
||||||
implements RunnableScanner<SyncStatus> {
|
implements RunnableScanner<SyncStatus>
|
||||||
|
{
|
||||||
private servers: RadarrSettings[];
|
private servers: RadarrSettings[];
|
||||||
private currentServer: RadarrSettings;
|
private currentServer: RadarrSettings;
|
||||||
private radarrApi: RadarrAPI;
|
private radarrApi: RadarrAPI;
|
||||||
@@ -72,7 +73,7 @@ class RadarrScanner
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async processRadarrMovie(radarrMovie: RadarrMovie): Promise<void> {
|
private async processRadarrMovie(radarrMovie: RadarrMovie): Promise<void> {
|
||||||
if (!radarrMovie.monitored && !radarrMovie.downloaded) {
|
if (!radarrMovie.monitored && !radarrMovie.hasFile) {
|
||||||
this.log(
|
this.log(
|
||||||
'Title is unmonitored and has not been downloaded. Skipping item.',
|
'Title is unmonitored and has not been downloaded. Skipping item.',
|
||||||
'debug',
|
'debug',
|
||||||
@@ -91,7 +92,7 @@ class RadarrScanner
|
|||||||
externalServiceId: radarrMovie.id,
|
externalServiceId: radarrMovie.id,
|
||||||
externalServiceSlug: radarrMovie.titleSlug,
|
externalServiceSlug: radarrMovie.titleSlug,
|
||||||
title: radarrMovie.title,
|
title: radarrMovie.title,
|
||||||
processing: !radarrMovie.downloaded,
|
processing: !radarrMovie.hasFile,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.log('Failed to process Radarr media', 'error', {
|
this.log('Failed to process Radarr media', 'error', {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { uniqWith } from 'lodash';
|
import { uniqWith } from 'lodash';
|
||||||
import { getRepository } from 'typeorm';
|
import { getRepository } from 'typeorm';
|
||||||
import SonarrAPI, { SonarrSeries } from '../../../api/servarr/sonarr';
|
import SonarrAPI, { SonarrSeries } from '../../../api/servarr/sonarr';
|
||||||
|
import { TmdbTvDetails } from '../../../api/themoviedb/interfaces';
|
||||||
import Media from '../../../entity/Media';
|
import Media from '../../../entity/Media';
|
||||||
import { getSettings, SonarrSettings } from '../../settings';
|
import { getSettings, SonarrSettings } from '../../settings';
|
||||||
import BaseScanner, {
|
import BaseScanner, {
|
||||||
@@ -16,7 +17,8 @@ type SyncStatus = StatusBase & {
|
|||||||
|
|
||||||
class SonarrScanner
|
class SonarrScanner
|
||||||
extends BaseScanner<SonarrSeries>
|
extends BaseScanner<SonarrSeries>
|
||||||
implements RunnableScanner<SyncStatus> {
|
implements RunnableScanner<SyncStatus>
|
||||||
|
{
|
||||||
private servers: SonarrSettings[];
|
private servers: SonarrSettings[];
|
||||||
private currentServer: SonarrSettings;
|
private currentServer: SonarrSettings;
|
||||||
private sonarrApi: SonarrAPI;
|
private sonarrApi: SonarrAPI;
|
||||||
@@ -82,24 +84,26 @@ class SonarrScanner
|
|||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
const server4k = this.enable4kShow && this.currentServer.is4k;
|
const server4k = this.enable4kShow && this.currentServer.is4k;
|
||||||
const processableSeasons: ProcessableSeason[] = [];
|
const processableSeasons: ProcessableSeason[] = [];
|
||||||
let tmdbId: number;
|
let tvShow: TmdbTvDetails;
|
||||||
|
|
||||||
const media = await mediaRepository.findOne({
|
const media = await mediaRepository.findOne({
|
||||||
where: { tvdbId: sonarrSeries.tvdbId },
|
where: { tvdbId: sonarrSeries.tvdbId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!media || !media.tmdbId) {
|
if (!media || !media.tmdbId) {
|
||||||
const tvShow = await this.tmdb.getShowByTvdbId({
|
tvShow = await this.tmdb.getShowByTvdbId({
|
||||||
tvdbId: sonarrSeries.tvdbId,
|
tvdbId: sonarrSeries.tvdbId,
|
||||||
});
|
});
|
||||||
|
|
||||||
tmdbId = tvShow.id;
|
|
||||||
} else {
|
} else {
|
||||||
tmdbId = media.tmdbId;
|
tvShow = await this.tmdb.getTvShow({ tvId: media.tmdbId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tmdbId = tvShow.id;
|
||||||
|
|
||||||
const filteredSeasons = sonarrSeries.seasons.filter(
|
const filteredSeasons = sonarrSeries.seasons.filter(
|
||||||
(sn) => sn.seasonNumber !== 0
|
(sn) =>
|
||||||
|
sn.seasonNumber !== 0 &&
|
||||||
|
tvShow.seasons.find((s) => s.season_number === sn.seasonNumber)
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const season of filteredSeasons) {
|
for (const season of filteredSeasons) {
|
||||||
|
|||||||
212
server/lib/search.ts
Normal file
212
server/lib/search.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import TheMovieDb from '../api/themoviedb';
|
||||||
|
import {
|
||||||
|
TmdbMovieDetails,
|
||||||
|
TmdbMovieResult,
|
||||||
|
TmdbPersonDetails,
|
||||||
|
TmdbPersonResult,
|
||||||
|
TmdbSearchMovieResponse,
|
||||||
|
TmdbSearchMultiResponse,
|
||||||
|
TmdbSearchTvResponse,
|
||||||
|
TmdbTvDetails,
|
||||||
|
TmdbTvResult,
|
||||||
|
} from '../api/themoviedb/interfaces';
|
||||||
|
import {
|
||||||
|
mapMovieDetailsToResult,
|
||||||
|
mapPersonDetailsToResult,
|
||||||
|
mapTvDetailsToResult,
|
||||||
|
} from '../models/Search';
|
||||||
|
import { isMovie, isMovieDetails, isTvDetails } from '../utils/typeHelpers';
|
||||||
|
|
||||||
|
interface SearchProvider {
|
||||||
|
pattern: RegExp;
|
||||||
|
search: ({
|
||||||
|
id,
|
||||||
|
language,
|
||||||
|
query,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
language?: string;
|
||||||
|
query?: string;
|
||||||
|
}) => Promise<TmdbSearchMultiResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchProviders: SearchProvider[] = [];
|
||||||
|
|
||||||
|
export const findSearchProvider = (
|
||||||
|
query: string
|
||||||
|
): SearchProvider | undefined => {
|
||||||
|
return searchProviders.find((provider) => provider.pattern.test(query));
|
||||||
|
};
|
||||||
|
|
||||||
|
searchProviders.push({
|
||||||
|
pattern: new RegExp(/(?<=tmdb:)\d+/),
|
||||||
|
search: async ({ id, language }) => {
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
|
const moviePromise = tmdb.getMovie({ movieId: parseInt(id), language });
|
||||||
|
const tvShowPromise = tmdb.getTvShow({ tvId: parseInt(id), language });
|
||||||
|
const personPromise = tmdb.getPerson({ personId: parseInt(id), language });
|
||||||
|
|
||||||
|
const responses = await Promise.allSettled([
|
||||||
|
moviePromise,
|
||||||
|
tvShowPromise,
|
||||||
|
personPromise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const successfulResponses = responses.filter(
|
||||||
|
(r) => r.status === 'fulfilled'
|
||||||
|
) as
|
||||||
|
| (
|
||||||
|
| PromiseFulfilledResult<TmdbMovieDetails>
|
||||||
|
| PromiseFulfilledResult<TmdbTvDetails>
|
||||||
|
| PromiseFulfilledResult<TmdbPersonDetails>
|
||||||
|
)[];
|
||||||
|
|
||||||
|
const results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[] = [];
|
||||||
|
|
||||||
|
if (successfulResponses.length) {
|
||||||
|
results.push(
|
||||||
|
...successfulResponses.map((r) => {
|
||||||
|
if (isMovieDetails(r.value)) {
|
||||||
|
return mapMovieDetailsToResult(r.value);
|
||||||
|
} else if (isTvDetails(r.value)) {
|
||||||
|
return mapTvDetailsToResult(r.value);
|
||||||
|
} else {
|
||||||
|
return mapPersonDetailsToResult(r.value);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: 1,
|
||||||
|
total_pages: 1,
|
||||||
|
total_results: results.length,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
searchProviders.push({
|
||||||
|
pattern: new RegExp(/(?<=imdb:)(tt|nm)\d+/),
|
||||||
|
search: async ({ id, language }) => {
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
|
const responses = await tmdb.getByExternalId({
|
||||||
|
externalId: id,
|
||||||
|
type: 'imdb',
|
||||||
|
language,
|
||||||
|
});
|
||||||
|
|
||||||
|
const results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[] = [];
|
||||||
|
|
||||||
|
// set the media_type here since searching by external id doesn't return it
|
||||||
|
results.push(
|
||||||
|
...(responses.movie_results.map((movie) => ({
|
||||||
|
...movie,
|
||||||
|
media_type: 'movie',
|
||||||
|
})) as TmdbMovieResult[]),
|
||||||
|
...(responses.tv_results.map((tv) => ({
|
||||||
|
...tv,
|
||||||
|
media_type: 'tv',
|
||||||
|
})) as TmdbTvResult[]),
|
||||||
|
...(responses.person_results.map((person) => ({
|
||||||
|
...person,
|
||||||
|
media_type: 'person',
|
||||||
|
})) as TmdbPersonResult[])
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: 1,
|
||||||
|
total_pages: 1,
|
||||||
|
total_results: results.length,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
searchProviders.push({
|
||||||
|
pattern: new RegExp(/(?<=tvdb:)\d+/),
|
||||||
|
search: async ({ id, language }) => {
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
|
const responses = await tmdb.getByExternalId({
|
||||||
|
externalId: parseInt(id),
|
||||||
|
type: 'tvdb',
|
||||||
|
language,
|
||||||
|
});
|
||||||
|
|
||||||
|
const results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[] = [];
|
||||||
|
|
||||||
|
// set the media_type here since searching by external id doesn't return it
|
||||||
|
results.push(
|
||||||
|
...(responses.movie_results.map((movie) => ({
|
||||||
|
...movie,
|
||||||
|
media_type: 'movie',
|
||||||
|
})) as TmdbMovieResult[]),
|
||||||
|
...(responses.tv_results.map((tv) => ({
|
||||||
|
...tv,
|
||||||
|
media_type: 'tv',
|
||||||
|
})) as TmdbTvResult[]),
|
||||||
|
...(responses.person_results.map((person) => ({
|
||||||
|
...person,
|
||||||
|
media_type: 'person',
|
||||||
|
})) as TmdbPersonResult[])
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: 1,
|
||||||
|
total_pages: 1,
|
||||||
|
total_results: results.length,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
searchProviders.push({
|
||||||
|
pattern: new RegExp(/(?<=year:)\d{4}/),
|
||||||
|
search: async ({ id: year, query }) => {
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
|
const moviesPromise = tmdb.searchMovies({
|
||||||
|
query: query?.replace(new RegExp(/year:\d{4}/), '') ?? '',
|
||||||
|
year: parseInt(year),
|
||||||
|
});
|
||||||
|
const tvShowsPromise = tmdb.searchTvShows({
|
||||||
|
query: query?.replace(new RegExp(/year:\d{4}/), '') ?? '',
|
||||||
|
year: parseInt(year),
|
||||||
|
});
|
||||||
|
|
||||||
|
const responses = await Promise.allSettled([moviesPromise, tvShowsPromise]);
|
||||||
|
|
||||||
|
const successfulResponses = responses.filter(
|
||||||
|
(r) => r.status === 'fulfilled'
|
||||||
|
) as
|
||||||
|
| (
|
||||||
|
| PromiseFulfilledResult<TmdbSearchMovieResponse>
|
||||||
|
| PromiseFulfilledResult<TmdbSearchTvResponse>
|
||||||
|
)[];
|
||||||
|
|
||||||
|
const results: (TmdbMovieResult | TmdbTvResult)[] = [];
|
||||||
|
|
||||||
|
if (successfulResponses.length) {
|
||||||
|
successfulResponses.forEach((response) => {
|
||||||
|
response.value.results.forEach((result) =>
|
||||||
|
// set the media_type here since the search endpoints don't return it
|
||||||
|
results.push(
|
||||||
|
isMovie(result)
|
||||||
|
? { ...result, media_type: 'movie' }
|
||||||
|
: { ...result, media_type: 'tv' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: 1,
|
||||||
|
total_pages: 1,
|
||||||
|
total_results: results.length,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -39,9 +39,18 @@ export interface PlexSettings {
|
|||||||
export interface JellyfinSettings {
|
export interface JellyfinSettings {
|
||||||
name: string;
|
name: string;
|
||||||
hostname?: string;
|
hostname?: string;
|
||||||
|
externalHostname?: string;
|
||||||
libraries: Library[];
|
libraries: Library[];
|
||||||
serverId: string;
|
serverId: string;
|
||||||
}
|
}
|
||||||
|
export interface TautulliSettings {
|
||||||
|
hostname?: string;
|
||||||
|
port?: number;
|
||||||
|
useSsl?: boolean;
|
||||||
|
urlBase?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
externalUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DVRSettings {
|
export interface DVRSettings {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -125,6 +134,7 @@ interface FullPublicSettings extends PublicSettings {
|
|||||||
enablePushRegistration: boolean;
|
enablePushRegistration: boolean;
|
||||||
locale: string;
|
locale: string;
|
||||||
emailEnabled: boolean;
|
emailEnabled: boolean;
|
||||||
|
newPlexLogin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationAgentConfig {
|
export interface NotificationAgentConfig {
|
||||||
@@ -137,6 +147,7 @@ export interface NotificationAgentDiscord extends NotificationAgentConfig {
|
|||||||
botUsername?: string;
|
botUsername?: string;
|
||||||
botAvatarUrl?: string;
|
botAvatarUrl?: string;
|
||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
|
enableMentions: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +193,7 @@ export interface NotificationAgentTelegram extends NotificationAgentConfig {
|
|||||||
export interface NotificationAgentPushbullet extends NotificationAgentConfig {
|
export interface NotificationAgentPushbullet extends NotificationAgentConfig {
|
||||||
options: {
|
options: {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
channelTag?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,9 +212,17 @@ export interface NotificationAgentWebhook extends NotificationAgentConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NotificationAgentGotify extends NotificationAgentConfig {
|
||||||
|
options: {
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export enum NotificationAgentKey {
|
export enum NotificationAgentKey {
|
||||||
DISCORD = 'discord',
|
DISCORD = 'discord',
|
||||||
EMAIL = 'email',
|
EMAIL = 'email',
|
||||||
|
GOTIFY = 'gotify',
|
||||||
PUSHBULLET = 'pushbullet',
|
PUSHBULLET = 'pushbullet',
|
||||||
PUSHOVER = 'pushover',
|
PUSHOVER = 'pushover',
|
||||||
SLACK = 'slack',
|
SLACK = 'slack',
|
||||||
@@ -214,6 +234,7 @@ export enum NotificationAgentKey {
|
|||||||
interface NotificationAgents {
|
interface NotificationAgents {
|
||||||
discord: NotificationAgentDiscord;
|
discord: NotificationAgentDiscord;
|
||||||
email: NotificationAgentEmail;
|
email: NotificationAgentEmail;
|
||||||
|
gotify: NotificationAgentGotify;
|
||||||
lunasea: NotificationAgentLunaSea;
|
lunasea: NotificationAgentLunaSea;
|
||||||
pushbullet: NotificationAgentPushbullet;
|
pushbullet: NotificationAgentPushbullet;
|
||||||
pushover: NotificationAgentPushover;
|
pushover: NotificationAgentPushover;
|
||||||
@@ -227,6 +248,20 @@ interface NotificationSettings {
|
|||||||
agents: NotificationAgents;
|
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'
|
||||||
|
| 'jellyfin-recently-added-sync'
|
||||||
|
| 'jellyfin-full-sync';
|
||||||
|
|
||||||
interface AllSettings {
|
interface AllSettings {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
vapidPublic: string;
|
vapidPublic: string;
|
||||||
@@ -234,10 +269,12 @@ interface AllSettings {
|
|||||||
main: MainSettings;
|
main: MainSettings;
|
||||||
plex: PlexSettings;
|
plex: PlexSettings;
|
||||||
jellyfin: JellyfinSettings;
|
jellyfin: JellyfinSettings;
|
||||||
|
tautulli: TautulliSettings;
|
||||||
radarr: RadarrSettings[];
|
radarr: RadarrSettings[];
|
||||||
sonarr: SonarrSettings[];
|
sonarr: SonarrSettings[];
|
||||||
public: PublicSettings;
|
public: PublicSettings;
|
||||||
notifications: NotificationSettings;
|
notifications: NotificationSettings;
|
||||||
|
jobs: Record<JobId, JobSettings>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
|
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
|
||||||
@@ -283,9 +320,11 @@ class Settings {
|
|||||||
jellyfin: {
|
jellyfin: {
|
||||||
name: '',
|
name: '',
|
||||||
hostname: '',
|
hostname: '',
|
||||||
|
externalHostname: '',
|
||||||
libraries: [],
|
libraries: [],
|
||||||
serverId: '',
|
serverId: '',
|
||||||
},
|
},
|
||||||
|
tautulli: {},
|
||||||
radarr: [],
|
radarr: [],
|
||||||
sonarr: [],
|
sonarr: [],
|
||||||
public: {
|
public: {
|
||||||
@@ -303,7 +342,7 @@ class Settings {
|
|||||||
ignoreTls: false,
|
ignoreTls: false,
|
||||||
requireTls: false,
|
requireTls: false,
|
||||||
allowSelfSigned: false,
|
allowSelfSigned: false,
|
||||||
senderName: 'Jellyseerr',
|
senderName: 'Overseerr',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
discord: {
|
discord: {
|
||||||
@@ -311,6 +350,7 @@ class Settings {
|
|||||||
types: 0,
|
types: 0,
|
||||||
options: {
|
options: {
|
||||||
webhookUrl: '',
|
webhookUrl: '',
|
||||||
|
enableMentions: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
lunasea: {
|
lunasea: {
|
||||||
@@ -357,13 +397,47 @@ class Settings {
|
|||||||
options: {
|
options: {
|
||||||
webhookUrl: '',
|
webhookUrl: '',
|
||||||
jsonPayload:
|
jsonPayload:
|
||||||
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJzdWJqZWN0XCI6IFwie3tzdWJqZWN0fX1cIixcbiAgICBcIm1lc3NhZ2VcIjogXCJ7e21lc3NhZ2V9fVwiLFxuICAgIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgICBcImVtYWlsXCI6IFwie3tub3RpZnl1c2VyX2VtYWlsfX1cIixcbiAgICBcInVzZXJuYW1lXCI6IFwie3tub3RpZnl1c2VyX3VzZXJuYW1lfX1cIixcbiAgICBcImF2YXRhclwiOiBcInt7bm90aWZ5dXNlcl9hdmF0YXJ9fVwiLFxuICAgIFwie3ttZWRpYX19XCI6IHtcbiAgICAgICAgXCJtZWRpYV90eXBlXCI6IFwie3ttZWRpYV90eXBlfX1cIixcbiAgICAgICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgICAgIFwiaW1kYklkXCI6IFwie3ttZWRpYV9pbWRiaWR9fVwiLFxuICAgICAgICBcInR2ZGJJZFwiOiBcInt7bWVkaWFfdHZkYmlkfX1cIixcbiAgICAgICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgICAgIFwic3RhdHVzNGtcIjogXCJ7e21lZGlhX3N0YXR1czRrfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW10sXG4gICAgXCJ7e3JlcXVlc3R9fVwiOiB7XG4gICAgICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfZW1haWxcIjogXCJ7e3JlcXVlc3RlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV91c2VybmFtZVwiOiBcInt7cmVxdWVzdGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIlxuICAgIH1cbn0i',
|
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
webpush: {
|
webpush: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
|
gotify: {
|
||||||
|
enabled: false,
|
||||||
|
types: 0,
|
||||||
|
options: {
|
||||||
|
url: '',
|
||||||
|
token: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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 * * *',
|
||||||
|
},
|
||||||
|
'jellyfin-recently-added-sync': {
|
||||||
|
schedule: '0 */5 * * * *',
|
||||||
|
},
|
||||||
|
'jellyfin-full-sync': {
|
||||||
|
schedule: '0 0 3 * * *',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -400,6 +474,14 @@ class Settings {
|
|||||||
this.data.jellyfin = data;
|
this.data.jellyfin = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get tautulli(): TautulliSettings {
|
||||||
|
return this.data.tautulli;
|
||||||
|
}
|
||||||
|
|
||||||
|
set tautulli(data: TautulliSettings) {
|
||||||
|
this.data.tautulli = data;
|
||||||
|
}
|
||||||
|
|
||||||
get radarr(): RadarrSettings[] {
|
get radarr(): RadarrSettings[] {
|
||||||
return this.data.radarr;
|
return this.data.radarr;
|
||||||
}
|
}
|
||||||
@@ -447,6 +529,7 @@ class Settings {
|
|||||||
enablePushRegistration: this.data.notifications.agents.webpush.enabled,
|
enablePushRegistration: this.data.notifications.agents.webpush.enabled,
|
||||||
locale: this.data.main.locale,
|
locale: this.data.main.locale,
|
||||||
emailEnabled: this.data.notifications.agents.email.enabled,
|
emailEnabled: this.data.notifications.agents.email.enabled,
|
||||||
|
newPlexLogin: this.data.main.newPlexLogin,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,6 +541,14 @@ class Settings {
|
|||||||
this.data.notifications = data;
|
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 {
|
get clientId(): string {
|
||||||
if (!this.data.clientId) {
|
if (!this.data.clientId) {
|
||||||
this.data.clientId = randomUUID();
|
this.data.clientId = randomUUID();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as winston from 'winston';
|
|||||||
import 'winston-daily-rotate-file';
|
import 'winston-daily-rotate-file';
|
||||||
|
|
||||||
// Migrate away from old log
|
// Migrate away from old log
|
||||||
const OLD_LOG_FILE = path.join(__dirname, '../config/logs/Jellyseerr.log');
|
const OLD_LOG_FILE = path.join(__dirname, '../config/logs/overseerr.log');
|
||||||
if (fs.existsSync(OLD_LOG_FILE)) {
|
if (fs.existsSync(OLD_LOG_FILE)) {
|
||||||
const file = fs.lstatSync(OLD_LOG_FILE);
|
const file = fs.lstatSync(OLD_LOG_FILE);
|
||||||
|
|
||||||
@@ -43,14 +43,30 @@ const logger = winston.createLogger({
|
|||||||
}),
|
}),
|
||||||
new winston.transports.DailyRotateFile({
|
new winston.transports.DailyRotateFile({
|
||||||
filename: process.env.CONFIG_DIRECTORY
|
filename: process.env.CONFIG_DIRECTORY
|
||||||
? `${process.env.CONFIG_DIRECTORY}/logs/Jellyseerr-%DATE%.log`
|
? `${process.env.CONFIG_DIRECTORY}/logs/overseerr-%DATE%.log`
|
||||||
: path.join(__dirname, '../config/logs/Jellyseerr-%DATE%.log'),
|
: path.join(__dirname, '../config/logs/overseerr-%DATE%.log'),
|
||||||
datePattern: 'YYYY-MM-DD',
|
datePattern: 'YYYY-MM-DD',
|
||||||
zippedArchive: true,
|
zippedArchive: true,
|
||||||
maxSize: '20m',
|
maxSize: '20m',
|
||||||
maxFiles: '7d',
|
maxFiles: '7d',
|
||||||
createSymlink: true,
|
createSymlink: true,
|
||||||
symlinkName: 'Jellyseerr.log',
|
symlinkName: 'overseerr.log',
|
||||||
|
}),
|
||||||
|
new winston.transports.DailyRotateFile({
|
||||||
|
filename: process.env.CONFIG_DIRECTORY
|
||||||
|
? `${process.env.CONFIG_DIRECTORY}/logs/.machinelogs-%DATE%.json`
|
||||||
|
: path.join(__dirname, '../config/logs/.machinelogs-%DATE%.json'),
|
||||||
|
datePattern: 'YYYY-MM-DD',
|
||||||
|
zippedArchive: true,
|
||||||
|
maxSize: '20m',
|
||||||
|
maxFiles: '1d',
|
||||||
|
createSymlink: true,
|
||||||
|
symlinkName: '.machinelogs.json',
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.splat(),
|
||||||
|
winston.format.timestamp(),
|
||||||
|
winston.format.json()
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
export class AddUserRequestDeleteCascades1608219049304
|
export class AddUserRequestDeleteCascades1608219049304
|
||||||
implements MigrationInterface {
|
implements MigrationInterface
|
||||||
|
{
|
||||||
name = 'AddUserRequestDeleteCascades1608219049304';
|
name = 'AddUserRequestDeleteCascades1608219049304';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
export class AddLastSeasonChangeMedia1608477467935
|
export class AddLastSeasonChangeMedia1608477467935
|
||||||
implements MigrationInterface {
|
implements MigrationInterface
|
||||||
|
{
|
||||||
name = 'AddLastSeasonChangeMedia1608477467935';
|
name = 'AddLastSeasonChangeMedia1608477467935';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
export class ForceDropImdbUniqueConstraint1608477467935
|
export class ForceDropImdbUniqueConstraint1608477467935
|
||||||
implements MigrationInterface {
|
implements MigrationInterface
|
||||||
|
{
|
||||||
name = 'ForceDropImdbUniqueConstraint1608477467936';
|
name = 'ForceDropImdbUniqueConstraint1608477467936';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
export class RemoveTmdbIdUniqueConstraint1609236552057
|
export class RemoveTmdbIdUniqueConstraint1609236552057
|
||||||
implements MigrationInterface {
|
implements MigrationInterface
|
||||||
|
{
|
||||||
name = 'RemoveTmdbIdUniqueConstraint1609236552057';
|
name = 'RemoveTmdbIdUniqueConstraint1609236552057';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
export class AddMediaAddedFieldToMedia1610522845513
|
export class AddMediaAddedFieldToMedia1610522845513
|
||||||
implements MigrationInterface {
|
implements MigrationInterface
|
||||||
|
{
|
||||||
name = 'AddMediaAddedFieldToMedia1610522845513';
|
name = 'AddMediaAddedFieldToMedia1610522845513';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
export class SonarrRadarrSyncServiceFields1611757511674
|
export class SonarrRadarrSyncServiceFields1611757511674
|
||||||
implements MigrationInterface {
|
implements MigrationInterface
|
||||||
|
{
|
||||||
name = 'SonarrRadarrSyncServiceFields1611757511674';
|
name = 'SonarrRadarrSyncServiceFields1611757511674';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user