Merge branch 'develop'

This commit is contained in:
Fallenbagel
2022-05-21 06:43:52 +05:00
315 changed files with 29562 additions and 14858 deletions

View File

@@ -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

View File

@@ -10,6 +10,7 @@
.gitconfig .gitconfig
.github .github
.gitignore .gitignore
.husky
.next .next
.prettierignore .prettierignore
config/db/* config/db/*

View File

@@ -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
View 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

View File

@@ -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.

View File

@@ -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
View 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

View File

@@ -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.

View File

@@ -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
View File

@@ -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
View File

@@ -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

View 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

View File

@@ -1,4 +1,4 @@
#!/bin/sh #!/bin/sh
. "$(dirname "$0")/_/husky.sh" . "$(dirname "$0")/_/husky.sh"
npm test npx lint-staged

View File

@@ -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

View File

@@ -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"
] ]
} }

View File

@@ -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
}, },

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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 ./

View File

@@ -1,4 +1,4 @@
FROM node:14.17-alpine FROM node:16.14-alpine
COPY . /app COPY . /app
WORKDIR /app WORKDIR /app

View File

@@ -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
[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](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.

View File

@@ -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).

View File

@@ -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)

View File

@@ -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';
} }
``` ```

View File

@@ -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

View File

@@ -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 %}

View File

@@ -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 &rarr; About** page in your Overseerr instance. You can find the changelog for your version (stable/`latest`,s or `develop`) in the **Settings &rarr; 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 &rarr; 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 &rarr; 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.

View File

@@ -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).

View File

@@ -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)

View 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 %}

View File

@@ -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.

View File

@@ -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).

View File

@@ -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

View File

@@ -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) |

View File

@@ -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 &rarr; 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 &rarr; 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
View File

@@ -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

View File

@@ -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: []

View File

@@ -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

View File

@@ -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>

View File

@@ -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);

View File

@@ -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 [];

View File

@@ -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>(

View File

@@ -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();
} }

View File

@@ -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(

View File

@@ -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[]>(

View File

@@ -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',
{ {

View File

@@ -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
View 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;

View File

@@ -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}`
); );
} }
} }

View File

@@ -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
View 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
View 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;

View 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;

View File

@@ -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}`;
} }
} }
} }

View File

@@ -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,
});
}
}
} }

View File

@@ -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> {

View File

@@ -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;

View File

@@ -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,

View File

@@ -0,0 +1,6 @@
import Issue from '../../entity/Issue';
import { PaginatedResponse } from './common';
export interface IssueResultsResponse extends PaginatedResponse {
results: Issue[];
}

View File

@@ -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;
};
}

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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',
}); });

View File

@@ -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,
}), }),

View File

@@ -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) {

View File

@@ -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);
}; };

View File

@@ -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> {

View File

@@ -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,

View File

@@ -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',

View 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;

View File

@@ -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,
}; };
} }

View File

@@ -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;
} }
} }

View File

@@ -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;
} }
} }

View File

@@ -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,
}; };
} }

View File

@@ -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',

View File

@@ -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') {

View File

@@ -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(

View File

@@ -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[] = [];

View File

@@ -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 {

View File

@@ -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

View File

@@ -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);

View File

@@ -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', {

View File

@@ -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
View 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,
};
},
});

View File

@@ -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();

View File

@@ -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()
),
}), }),
], ],
}); });

View File

@@ -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> {

View File

@@ -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> {

View File

@@ -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> {

View File

@@ -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> {

View File

@@ -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> {

View File

@@ -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