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": [
"code"
]
}
},
{
"login": "Fallenbagel",
"name": "Mohamed Nuvaas",
"avatar_url": "https://avatars.githubusercontent.com/u/98979876?s=96&v=4",
"profile": "https://github.com/nicospz",
"login": "sootylunatic",
"name": "sootylunatic",
"avatar_url": "https://avatars.githubusercontent.com/u/36486087?v=4",
"profile": "https://github.com/sootylunatic",
"contributions": [
"code",
"logo",
"design"
"translation"
]
},
{
"login": "JoKerIsCraZy",
"name": "JoKerIsCraZy",
"avatar_url": "https://avatars.githubusercontent.com/u/47474211?v=4",
"profile": "https://github.com/JoKerIsCraZy",
"contributions": [
"translation"
]
},
{
"login": "GoByeBye",
"name": "Daddie0",
"avatar_url": "https://avatars.githubusercontent.com/u/33762262?v=4",
"profile": "https://daddie.dev",
"contributions": [
"translation"
]
},
{
"login": "Simoneu01",
"name": "Simone",
"avatar_url": "https://avatars.githubusercontent.com/u/43807696?v=4",
"profile": "http://ungaro.me",
"contributions": [
"translation"
]
},
{
"login": "adan89lion",
"name": "Seohyun Joo",
"avatar_url": "https://avatars.githubusercontent.com/u/6585644?v=4",
"profile": "https://github.com/adan89lion",
"contributions": [
"translation"
]
},
{
"login": "ty4ko",
"name": "Sergey",
"avatar_url": "https://avatars.githubusercontent.com/u/21213535?v=4",
"profile": "https://github.com/ty4ko",
"contributions": [
"translation"
]
},
{
"login": "skafte1990",
"name": "Shaaft",
"avatar_url": "https://avatars.githubusercontent.com/u/31465453?v=4",
"profile": "https://github.com/skafte1990",
"contributions": [
"translation"
]
},
{
"login": "sr093906",
"name": "sr093906",
"avatar_url": "https://avatars.githubusercontent.com/u/8369201?v=4",
"profile": "https://github.com/sr093906",
"contributions": [
"translation"
]
},
{
"login": "Nackophilz",
"name": "Nackophilz",
"avatar_url": "https://avatars.githubusercontent.com/u/61667226?v=4",
"profile": "https://github.com/Nackophilz",
"contributions": [
"translation"
]
},
{
"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>",
"contributorsPerLine": 7,
"projectName": "jellyseerr",
"projectOwner": "Fallenbagel",
"projectName": "overseerr",
"projectOwner": "sct",
"repoType": "github",
"repoHost": "https://github.com",
"skipCi": true

View File

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

View File

@@ -7,6 +7,7 @@ module.exports = {
'plugin:jsx-a11y/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'prettier',
],
parserOptions: {
ecmaVersion: 6,
@@ -25,6 +26,7 @@ module.exports = {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'@typescript-eslint/explicit-function-return-type': 'off',
'prettier/prettier': ['error', { endOfLine: 'auto' }],
'formatjs/no-offset': 'error',
'no-unused-vars': 'off',
'@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: {
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
contact_links:
- name: Support via Discord
url: https://discord.gg/overseerr
about: Chat with users and devs on support and setup related topics.
- name: Support via GitHub Discussions
url: https://github.com/sct/overseerr/discussions
- name: 💬 Support via Discord
url: https://discord.gg/ckbvBtDJgC
about: Chat with other users and the Overseerr dev team
- name: 💬 Support via GitHub Discussions
url: https://github.com/fallenbagel/jellyseerr/discussions
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
- [ ] Successful build `yarn build`
- [ ] Translation keys `yarn i18n:extract`
- [ ] Database migration (if required)
#### 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
# Number of days of inactivity before a stale issue is closed
# Number of days of inactivity before an Issue or Pull Request with the stale label is closed.
# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
daysUntilClose: 7
# Issues with these labels will never be considered stale
# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable
exemptLabels:
- pinned
- security
- dependencies
# Label to use when marking an issue as stale
- never-stale
- priority:high
- priority:medium
# Label to use when marking as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
# Comment to post when marking as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false
# Comment to post when removing the stale label.
# unmarkComment: >
# Your comment here.
# Comment to post when closing a stale Issue or Pull Request.
# closeComment: >
# Your comment here.
# Limit to only `issues` or `pulls`
# only: issues
# Optionally, specify configuration settings that are specific to just 'issues' or 'pulls':
pulls:
markComment: >
This pull request has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.

1
.gitignore vendored
View File

@@ -39,6 +39,7 @@ config/settings.json
config/logs/*.log*
config/logs/*.json
config/logs/*.log.gz
config/logs/*.json.gz
config/logs/*-audit.json
# anidb mapping file

View File

@@ -1,4 +1,4 @@
#!/bin/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
. "$(dirname "$0")/_/husky.sh"
npm test
npx lint-staged

View File

@@ -1,4 +1,4 @@
#!/bin/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",
"bradlc.vscode-tailwindcss",
// https://marketplace.visualstudio.com/items?itemName=heybourn.headwind
"heybourn.headwind"
"bradlc.vscode-tailwindcss"
]
}

View File

@@ -15,7 +15,6 @@
"database": "./config/db/db.sqlite3"
}
],
"i18n-ally.localesPaths": ["src/i18n", "src/i18n/locale"],
"editor.codeActionsOnSave": {
"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`:
```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:
@@ -66,17 +66,17 @@ All help is welcome and greatly appreciated! If you would like to contribute to
### 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/)
- 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.
- 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.
- 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.
- 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.
- 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.
### UI Text Style
@@ -97,7 +97,7 @@ When adding new UI text, please try to adhere to the following guidelines:
## 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>

View File

@@ -1,4 +1,4 @@
FROM node:14.17-alpine AS BUILD_IMAGE
FROM node:16.14-alpine AS BUILD_IMAGE
WORKDIR /app
@@ -7,8 +7,10 @@ ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
RUN \
case "${TARGETPLATFORM}" in \
'linux/arm64') apk add --no-cache python make g++ ;; \
'linux/arm/v7') apk add --no-cache python make g++ ;; \
'linux/arm64' | 'linux/arm/v7') \
apk add --no-cache python3 make g++ && \
ln -s /usr/bin/python3 /usr/bin/python \
;; \
esac
COPY package.json yarn.lock ./
@@ -24,18 +26,18 @@ RUN yarn build
# remove development dependencies
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 echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
FROM node:14.17-alpine
FROM node:16.14-alpine
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 /app ./

View File

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

View File

@@ -3,49 +3,57 @@
</p>
<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://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>
**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
- Jellyfin support
- Easy integration with your existing services. Currently, Jellyseerr supports Sonarr and Radarr.
- Jellyfin library scan, to keep track of the titles which are already available.
- Jellyfin Support
- Emby Support
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.
- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests!
- Granular permission system.
- Support for various notification agents.
- Mobile-friendly design, for when you need to approve requests on the go!
Check out our [issue tracker](https://github.com/Fallenbagel/jellyseerr/issues).
## 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 |
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.
## Getting Started
Check out our dockerhub for instructions on how to install and run Jellyseerr:
https://hub.docker.com/r/fallenbagel/jellyseerr
## Preview
<img src="./public/preview.jpg">
## Support
- 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 -->
<!-- prettier-ignore-end -->
## API Documentation
## 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.
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)
- [Web Push](using-overseerr/notifications/webpush.md)
- [Discord](using-overseerr/notifications/discord.md)
- [Gotify](using-overseerr/notifications/gotify.md)
- [LunaSea](using-overseerr/notifications/lunasea.md)
- [Pushbullet](using-overseerr/notifications/pushbullet.md)
- [Pushover](using-overseerr/notifications/pushover.md)

View File

@@ -145,8 +145,7 @@ location ^~ /overseerr {
sub_filter '/android-' '/$app/android-';
sub_filter '/apple-' '/$app/apple-';
sub_filter '/favicon' '/$app/favicon';
sub_filter '/logo_full.svg' '/$app/logo_full.svg';
sub_filter '/logo_stacked.svg' '/$app/logo_stacked.svg';
sub_filter '/logo_' '/$app/logo_';
sub_filter '/site.webmanifest' '/$app/site.webmanifest';
}
```

View File

@@ -1,13 +1,15 @@
# Third-Party Integrations
{% hint style="warning" %}
We do not officially support these third-party integrations. If you run into any issues, please seek help on the appropriate support channels for the integration itself!
**We do not officially support these third-party integrations.** If you run into any issues, please seek help on the appropriate support channels for the integration itself!
{% endhint %}
- [Organizr](https://organizr.app/), a HTPC/homelab services organizer
- [Heimdall](https://github.com/linuxserver/Heimdall), an application dashboard and launcher
- [LunaSea](https://docs.lunasea.app/modules/overseerr), a self-hosted controller for mobile and macOS
- [Requestrr](https://github.com/darkalfx/requestrr/wiki/Configuring-Overseerr), a Discord chatbot
- [Doplarr](https://github.com/kiranshila/Doplarr), a Discord request bot
- [Overseerr Assistant](https://github.com/RemiRigal/Overseerr-Assistant), a browser extension for requesting directly from TMDb and IMDb
- [ha-overseerr](https://github.com/vaparr/ha-overseerr), a custom Home Assistant component
- [OverCLIrr](https://github.com/WillFantom/OverCLIrr), a command-line tool
- [Overseerr Exporter](https://github.com/WillFantom/overseerr-exporter), a Prometheus exporter

View File

@@ -1,7 +1,7 @@
# Installation
{% 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 %}
{% hint style="info" %}
@@ -10,8 +10,18 @@ After running Overseerr for the first time, configure it by visiting the web UI
## Docker
{% hint style="warning" %}
Be sure to replace `/path/to/appdata/config` in the below examples with a valid host directory path. If this volume mount is not configured correctly, your Overseerr settings/data will not be persisted when the container is recreated (e.g., when updating the image or rebooting your machine).
The `TZ` environment variable value should also be set to the [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) of your time zone!
{% endhint %}
{% tabs %}
{% tab title="Basic" %}
{% tab title="Docker CLI" %}
For details on the Docker CLI, please [review the official `docker run` documentation](https://docs.docker.com/engine/reference/run/).
**Installation:**
```bash
docker run -d \
@@ -21,14 +31,44 @@ docker run -d \
-p 5055:5055 \
-v /path/to/appdata/config:/app/config \
--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 %}
{% 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
---
@@ -36,7 +76,7 @@ version: '3'
services:
overseerr:
image: sctx/overseerr:latest
image: fallenbagel/jellyseerr:latest
container_name: overseerr
environment:
- LOG_LEVEL=debug
@@ -48,47 +88,29 @@ services:
restart: unless-stopped
```
{% endtab %}
{% tab title="UID/GID" %}
```text
docker run -d \
--name overseerr \
--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ] \
-e LOG_LEVEL=debug \
-e TZ=Asia/Tokyo \
-p 5055:5055 \
-v /path/to/appdata/config:/app/config \
--restart unless-stopped \
sctx/overseerr
```
{% endtab %}
{% tab title="Manual Update" %}
Then, start all services defined in the your Compose file:
```bash
# Stop the Overseerr container
docker stop overseerr
docker-compose up -d
```
# Remove the Overseerr container
docker rm overseerr
**Updating:**
# Pull the latest update
docker pull sctx/overseerr
Pull the latest image:
# Run the Overseerr container with the same parameters as before
docker run -d ...
```bash
docker-compose pull overseerr
```
Then, restart all services defined in the Compose file:
```bash
docker-compose up -d
```
{% endtab %}
{% endtabs %}
{% hint style="info" %}
Use a 3rd party updating mechanism such as [Watchtower](https://github.com/containrrr/watchtower) or [Ouroboros](https://github.com/pyouroboros/ouroboros) to keep Overseerr up-to-date automatically.
{% endhint %}
## Unraid
1. Ensure you have the **Community Applications** plugin installed.
@@ -121,7 +143,7 @@ or the Docker Desktop app:
Then, create and start the Overseerr container:
```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.
@@ -144,29 +166,24 @@ The [Overseerr snap](https://snapcraft.io/overseerr) is the only officially supp
Currently, the listening port cannot be changed, so port `5055` will need to be available on your host. To install `snapd`, please refer to the [Snapcraft documentation](https://snapcraft.io/docs/installing-snapd).
{% endhint %}
**To install:**
**Installation:**
```
sudo snap install overseerr
```
{% hint style="danger" %}
To install the development build, add the `--edge` argument to the above command (i.e., `sudo snap install overseerr --edge`). However, note that this version can break any moment. Be prepared to troubleshoot any issues that arise!
{% endhint %}
**Updating:**
Snap will keep Overseerr up-to-date automatically. You can force a refresh by using the following command.
```
```bash
sudo snap refresh
```
**To install the development build:**
```
sudo snap install overseerr --edge
```
{% hint style="danger" %}
This version can break any moment. Be prepared to troubleshoot any issues that arise!
{% endhint %}
## Third-Party
{% tabs %}

View File

@@ -1,7 +1,7 @@
# Frequently Asked Questions (FAQ)
{% 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!_
{% 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`.
### Are there mobile apps for Overseerr?
Since Overseerr has an almost native app experience when installed as a Progressive Web App (PWA), there are no plans to develop mobile apps for Overseerr.
Out of the box, Overseerr already fulfills most of the [PWA install criteria](https://web.dev/install-criteria/). You simply need to make sure that your Overseerr instance is being served over HTTPS (e.g., via a [reverse proxy](../extending-overseerr/reverse-proxy.md)).
### Overseerr is amazing! But it is not translated in my language yet! Can I help with translations?
You sure can! We are using [Weblate](https://hosted.weblate.org/engage/overseerr/) for translations. If your language is not listed, please [open a feature request on GitHub](https://github.com/sct/overseerr/issues/new/choose).
@@ -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 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!
@@ -82,7 +88,7 @@ Yes! Please see the [documentation for creating local users](../using-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
@@ -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.
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
### I am getting "Username and Password not accepted" when attempting to send email notifications via Gmail!
If you have 2-Step Verification enabled on your account, you will need to create an [app password](https://support.google.com/mail/answer/185833).
### The logo image in email notifications is broken!
This may be an issue with how you are proxying your Overseerr instance. A good first troubleshooting step is to verify that the [`Content-Security-Policy` HTTP header](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) being set by your proxy (if any) is configured appropriately to allow external embedding of the image.
For Gmail users, another possible issue is that Google's image URL proxy is being blocked from fetching the image. If using Cloudflare, overzealous firewall rules could be the culprit.

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!
- **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.)
@@ -19,6 +19,11 @@ Please try to include as much information as possible. A vague statement like "i
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 command did you enter?
- 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`.
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)
- [Web Push](./webpush.md)
- [Discord](./discord.md)
- [Gotify](./gotify.md)
- [LunaSea](./lunasea.md)
- [Pushbullet](./pushbullet.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
{% hint style="info" %}
Users can optionally configure personal notifications in their user settings.
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
{% endhint %}
## Configuration
### Access Token
[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
{% hint style="info" %}
Users can optionally configure personal notifications in their user settings.
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
{% endhint %}
## Configuration
### Application/API Token
[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).

View File

@@ -1,7 +1,9 @@
# Telegram
{% hint style="info" %}
Users can optionally configure their own notifications in their user settings.
Users can optionally configure personal notifications in their user settings.
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
{% endhint %}
## Configuration

View File

@@ -24,33 +24,38 @@ Customize the JSON payload to suit your needs. Overseerr provides several [templ
### General
- `{{notification_type}}` The type of notification. (Ex. `MEDIA_PENDING` or `MEDIA_APPROVED`)
- `{{subject}}` The notification subject message. (For request notifications, this is the media title)
- `{{message}}` Notification message body. (For request notifications, this is the media's overview/synopsis)
- `{{image}}` Associated image with the request. (For request notifications, this is the media's poster)
| Variable | Value |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `{{notification_type}}` | The type of notification (e.g. `MEDIA_PENDING` or `ISSUE_COMMENT`) |
| `{{event}}` | A friendly description of the notification event |
| `{{subject}}` | The notification subject (typically the media title) |
| `{{message}}` | The notification message body (the media overview/synopsis for request notifications; the issue description for issue notificatons) |
| `{{image}}` | The notification image (typically the media poster) |
### User
### Notify User
These variables are for the target recipient of the notification.
- `{{notifyuser_username}}` Target user's username.
- `{{notifyuser_email}}` Target user's email address.
- `{{notifyuser_avatar}}` Target user's avatar URL.
- `{{notifyuser_settings_discordId}}` Target user's Discord ID (if one is set).
- `{{notifyuser_settings_telegramChatId}}` Target user's Telegram Chat ID (if one is set).
| Variable | Value |
| ---------------------------------------- | ------------------------------------------------------------- |
| `{{notifyuser_username}}` | The target notification recipient's username |
| `{{notifyuser_email}}` | The target notification recipient's email address |
| `{{notifyuser_avatar}}` | The target notification recipient's avatar URL |
| `{{notifyuser_settings_discordId}}` | The target notification recipient's Discord ID (if set) |
| `{{notifyuser_settings_telegramChatId}}` | The target notification recipient's Telegram Chat ID (if set) |
{% hint style="info" %}
The `notifyuser` variables are not set for the following notification types, as they are intended for application administrators rather than end users:
The `notifyuser` variables are not defined for the following request notification types, as they are intended for application administrators rather than end users:
- Media Requested
- Media Automatically Approved
- Media Failed
- Request Pending Approval
- Request Automatically Approved
- Request Processing Failed
On the other hand, the `notifyuser` variables _will_ be replaced with the requesting user's information for the below notification types:
- Media Approved
- Media Declined
- Media Available
- Request Approved
- Request Declined
- 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.
{% 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}}": []`).
- `{{request}}` This object will be `null` if there is no relevant request object for the notification.
- `{{media}}` This object will be `null` if there is no relevant media object for the notification.
- `{{extra}}` This object will contain the "extra" array of additional data for certain notifications.
| Variable | Value |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `{{media}}` | The relevant media object |
| `{{request}}` | The relevant request object |
| `{{issue}}` | The relevant issue object |
| `{{comment}}` | The relevant issue comment object |
| `{{extra}}` | The "extra" array of additional data for certain notifications (e.g., season/episode numbers for series-related notifications) |
#### Media
These `{{media}}` special variables are only included in media-related notifications, such as requests.
The `{{media}}` will be `null` if there is no relevant media object for the notification.
- `{{media_type}}` Media type (`movie` or `tv`).
- `{{media_tmdbid}}` Media's TMDb ID.
- `{{media_imdbid}}` Media's IMDb ID.
- `{{media_tvdbid}}` Media's TVDB ID.
- `{{media_status}}` Media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`).
- `{{media_status4k}}` Media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`)
These following special variables are only included in media-related notifications, such as requests.
| Variable | Value |
| -------------------- | -------------------------------------------------------------------------------------------------------------- |
| `{{media_type}}` | The media type (`movie` or `tv`) |
| `{{media_tmdbid}}` | The media's TMDb ID |
| `{{media_tvdbid}}` | The media's TheTVDB ID |
| `{{media_status}}` | The media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
| `{{media_status4k}}` | The media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
#### Request
The `{{request}}` special variables are only included in request-related notifications.
The `{{request}}` will be `null` if there is no relevant media object for the notification.
- `{{request_id}}` Request ID.
- `{{requestedBy_username}}` Requesting user's username.
- `{{requestedBy_email}}` Requesting user's email address.
- `{{requestedBy_avatar}}` Requesting user's avatar URL.
- `{{requestedBy_settings_discordId}}` Requesting user's Discord ID (if set).
- `{{requestedBy_settings_telegramChatId}}` Requesting user's Telegram Chat ID (if set).
The following special variables are only included in request-related notifications.
| Variable | Value |
| ----------------------------------------- | ----------------------------------------------- |
| `{{request_id}}` | The request ID |
| `{{requestedBy_username}}` | The requesting user's username |
| `{{requestedBy_email}}` | The requesting user's email address |
| `{{requestedBy_avatar}}` | The requesting user's avatar URL |
| `{{requestedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
| `{{requestedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
#### Issue
The `{{issue}}` will be `null` if there is no relevant media object for the notification.
The following special variables are only included in issue-related notifications.
| Variable | Value |
| ---------------------------------------- | ----------------------------------------------- |
| `{{issue_id}}` | The issue ID |
| `{{reportedBy_username}}` | The requesting user's username |
| `{{reportedBy_email}}` | The requesting user's email address |
| `{{reportedBy_avatar}}` | The requesting user's avatar URL |
| `{{reportedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
| `{{reportedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
#### Comment
The `{{comment}}` will be `null` if there is no relevant media object for the notification.
The following special variables are only included in issue comment-related notifications.
| Variable | Value |
| ----------------------------------------- | ----------------------------------------------- |
| `{{comment_message}}` | The comment message |
| `{{commentedBy_username}}` | The commenting user's username |
| `{{commentedBy_email}}` | The commenting user's email address |
| `{{commentedBy_avatar}}` | The commenting user's avatar URL |
| `{{commentedBy_settings_discordId}}` | The commenting user's Discord ID (if set) |
| `{{commentedBy_settings_telegramChatId}}` | The commenting user's Telegram Chat ID (if set) |

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**.
### 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.

1
next-env.d.ts vendored
View File

@@ -1,5 +1,4 @@
/// <reference types="next" />
/// <reference types="next/types/global" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited

View File

@@ -171,6 +171,9 @@ components:
port:
type: number
example: 32400
useSsl:
type: boolean
nullable: true
libraries:
type: array
readOnly: true
@@ -178,6 +181,7 @@ components:
$ref: '#/components/schemas/PlexLibrary'
webAppUrl:
type: string
nullable: true
example: 'https://app.plex.tv/desktop'
required:
- name
@@ -329,6 +333,9 @@ components:
hostname:
type: string
example: 'http://my.jellyfin.host'
externalHostname:
type: string
example: 'http://my.jellyfin.host'
adminUser:
type: string
example: 'admin'
@@ -343,8 +350,26 @@ components:
serverID:
type: string
readOnly: true
required:
- hostname
TautulliSettings:
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:
type: object
properties:
@@ -956,6 +981,15 @@ components:
type: array
items:
$ref: '#/components/schemas/ProductionCompany'
productionCountries:
type: array
items:
type: object
properties:
iso_3166_1:
type: string
name:
type: string
spokenLanguages:
type: array
items:
@@ -1176,6 +1210,8 @@ components:
type: string
webhookUrl:
type: string
enableMentions:
type: boolean
SlackSettings:
type: object
properties:
@@ -1251,6 +1287,9 @@ components:
properties:
accessToken:
type: string
channelTag:
type: string
nullable: true
PushoverSettings:
type: object
properties:
@@ -1267,6 +1306,22 @@ components:
type: string
userToken:
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:
type: object
properties:
@@ -1325,7 +1380,28 @@ components:
allowSelfSigned:
type: boolean
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
properties:
id:
@@ -1656,6 +1732,15 @@ components:
discordId:
type: string
nullable: true
pushbulletAccessToken:
type: string
nullable: true
pushoverApplicationToken:
type: string
nullable: true
pushoverUserKey:
type: string
nullable: true
telegramEnabled:
type: boolean
telegramBotUsername:
@@ -1713,6 +1798,36 @@ components:
type: number
name:
type: string
Issue:
type: object
properties:
id:
type: number
example: 1
issueType:
type: number
example: 1
media:
$ref: '#/components/schemas/MediaInfo'
createdBy:
$ref: '#/components/schemas/User'
modifiedBy:
$ref: '#/components/schemas/User'
comments:
type: array
items:
$ref: '#/components/schemas/IssueComment'
IssueComment:
type: object
properties:
id:
type: number
example: 1
user:
$ref: '#/components/schemas/User'
message:
type: string
example: A comment
securitySchemes:
cookieAuth:
type: apiKey
@@ -1870,6 +1985,20 @@ paths:
type: array
items:
$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:
get:
summary: Get status of full Jellyfin library sync
@@ -2084,6 +2213,67 @@ paths:
type: array
items:
$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:
get:
summary: Get Radarr settings
@@ -2391,23 +2581,7 @@ paths:
schema:
type: array
items:
type: object
properties:
id:
type: string
example: job-name
name:
type: string
example: A Job Name
type:
type: string
enum: [process, command]
nextExecutionTime:
type: string
example: '2020-09-02T05:02:23.000Z'
running:
type: boolean
example: false
$ref: '#/components/schemas/Job'
/settings/jobs/{jobId}/run:
post:
summary: Invoke a specific job
@@ -2426,23 +2600,7 @@ paths:
content:
application/json:
schema:
type: object
properties:
id:
type: string
example: job-name
type:
type: string
enum: [process, command]
name:
type: string
example: A Job Name
nextExecutionTime:
type: string
example: '2020-09-02T05:02:23.000Z'
running:
type: boolean
example: false
$ref: '#/components/schemas/Job'
/settings/jobs/{jobId}/cancel:
post:
summary: Cancel a specific job
@@ -2458,26 +2616,39 @@ paths:
responses:
'200':
description: Canceled job returned
content:
application/json:
schema:
$ref: '#/components/schemas/Job'
/settings/jobs/{jobId}/schedule:
post:
summary: Modify job schedule
description: Re-registers the job with the schedule specified. Will return the job in JSON format.
tags:
- settings
parameters:
- in: path
name: jobId
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
id:
schedule:
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
example: '0 */5 * * * *'
responses:
'200':
description: Rescheduled job
content:
application/json:
schema:
$ref: '#/components/schemas/Job'
/settings/cache:
get:
summary: Get a list of active caches
@@ -2575,7 +2746,7 @@ paths:
example: Server ready on port 5055
timestamp:
type: string
example: 2020-12-15T16:20:00.069Z
example: '2020-12-15T16:20:00.069Z'
/settings/notifications/email:
get:
summary: Get email notification settings
@@ -2806,6 +2977,52 @@ paths:
responses:
'204':
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:
get:
summary: Get Slack notification settings
@@ -3017,6 +3234,9 @@ paths:
type: string
nullable: true
example: Asia/Tokyo
appDataPath:
type: string
example: /app/config
/auth/me:
get:
summary: Get logged-in user
@@ -3169,6 +3389,13 @@ paths:
security: []
tags:
- users
parameters:
- in: path
name: guid
required: true
schema:
type: number
example: 1
responses:
'200':
description: OK
@@ -3291,11 +3518,51 @@ paths:
post:
summary: Import all users from Plex
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.
tags:
- 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:
'201':
description: A list of the newly created users
@@ -3697,6 +3964,35 @@ paths:
permissions:
type: number
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:
get:
summary: Search for movies, TV shows, or people
@@ -4476,21 +4772,22 @@ paths:
schema:
type: object
properties:
total:
type: number
movie:
type: number
tv:
type: number
pending:
type: number
example: 0
approved:
type: number
example: 10
declined:
type: number
processing:
type: number
example: 4
available:
type: number
example: 6
required:
- pending
- approved
/request/{requestId}:
get:
summary: Get MediaRequest
@@ -4966,8 +5263,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/PersonDetail'
$ref: '#/components/schemas/PersonDetails'
/person/{personId}/combined_credits:
get:
summary: Get combined credits
@@ -5104,6 +5400,57 @@ paths:
application/json:
schema:
$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}:
get:
summary: Get collection details
@@ -5374,7 +5721,267 @@ paths:
name:
type: string
example: Drama
/backdrops:
get:
summary: Get backdrops of trending items
description: Returns a list of backdrop image paths in a JSON array.
security: []
tags:
- tmdb
responses:
'200':
description: Results
content:
application/json:
schema:
type: array
items:
type: string
/issue:
get:
summary: Get all issues
description: |
Returns a list of issues in JSON format.
tags:
- issue
parameters:
- in: query
name: take
schema:
type: number
nullable: true
example: 20
- in: query
name: skip
schema:
type: number
nullable: true
example: 0
- in: query
name: sort
schema:
type: string
enum: [added, modified]
default: added
- in: query
name: filter
schema:
type: string
enum: [all, open, resolved]
default: open
- in: query
name: requestedBy
schema:
type: number
nullable: true
example: 1
responses:
'200':
description: Issues returned
content:
application/json:
schema:
type: object
properties:
pageInfo:
$ref: '#/components/schemas/PageInfo'
results:
type: array
items:
$ref: '#/components/schemas/Issue'
post:
summary: Create new issue
description: |
Creates a new issue
tags:
- issue
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
issueType:
type: number
message:
type: string
mediaId:
type: number
responses:
'201':
description: Succesfully created the issue
content:
application/json:
schema:
$ref: '#/components/schemas/Issue'
/issue/{issueId}:
get:
summary: Get issue
description: |
Returns a single issue in JSON format.
tags:
- issue
parameters:
- in: path
name: issueId
required: true
schema:
type: number
example: 1
responses:
'200':
description: Issues returned
content:
application/json:
schema:
$ref: '#/components/schemas/Issue'
delete:
summary: Delete issue
description: Removes an issue. If the user has the `MANAGE_ISSUES` permission, any issue can be removed. Otherwise, only a users own issues can be removed.
tags:
- issue
parameters:
- in: path
name: issueId
description: Issue ID
required: true
example: '1'
schema:
type: string
responses:
'204':
description: Succesfully removed issue
/issue/{issueId}/comment:
post:
summary: Create a comment
description: |
Creates a comment and returns associated issue in JSON format.
tags:
- issue
parameters:
- in: path
name: issueId
required: true
schema:
type: number
example: 1
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
message:
type: string
required:
- message
responses:
'200':
description: Issue returned with new comment
content:
application/json:
schema:
$ref: '#/components/schemas/Issue'
/issueComment/{commentId}:
get:
summary: Get issue comment
description: |
Returns a single issue comment in JSON format.
tags:
- issue
parameters:
- in: path
name: commentId
required: true
schema:
type: string
example: 1
responses:
'200':
description: Comment returned
content:
application/json:
schema:
$ref: '#/components/schemas/IssueComment'
put:
summary: Update issue comment
description: |
Updates and returns a single issue comment in JSON format.
tags:
- issue
parameters:
- in: path
name: commentId
required: true
schema:
type: string
example: 1
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
message:
type: string
responses:
'200':
description: Comment updated
content:
application/json:
schema:
$ref: '#/components/schemas/IssueComment'
delete:
summary: Delete issue comment
description: |
Deletes an issue comment. Only users with `MANAGE_ISSUES` or the user who created the comment can perform this action.
tags:
- issue
parameters:
- in: path
name: commentId
description: Issue Comment ID
required: true
example: '1'
schema:
type: string
responses:
'204':
description: Succesfully removed issue comment
/issue/{issueId}/{status}:
post:
summary: Update an issue's status
description: |
Updates an issue's status to approved or declined. Also returns the issue in a JSON object.
Requires the `MANAGE_ISSUES` permission or `ADMIN`.
tags:
- issue
parameters:
- in: path
name: issueId
description: Issue ID
required: true
schema:
type: string
example: '1'
- in: path
name: status
description: New status
required: true
schema:
type: string
enum: [open, resolved]
responses:
'200':
description: Issue status changed
content:
application/json:
schema:
$ref: '#/components/schemas/Issue'
security:
- cookieAuth: []
- apiKey: []

View File

@@ -1,6 +1,6 @@
{
"name": "jellyseerr",
"version": "1.0.2",
"version": "1.29.1",
"private": true,
"scripts": {
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node --files --project server/tsconfig.json server/index.ts",
@@ -10,155 +10,151 @@
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\"",
"start": "NODE_ENV=production node dist/index.js",
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault true \"./src/**/!(*.test).{ts,tsx}\"",
"migration:generate": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:generate",
"migration:create": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:create",
"migration:run": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:run",
"format": "prettier --write ."
"migration:generate": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate",
"migration:create": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create",
"migration:run": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run",
"format": "prettier --write .",
"prepare": "husky install"
},
"repository": {
"type": "git",
"url": "https://github.com/fallenbagel/jellyseerr.git"
},
"license": "MIT",
"dependencies": {
"@headlessui/react": "^1.4.1",
"@heroicons/react": "^1.0.4",
"@supercharge/request-ip": "^1.1.2",
"@svgr/webpack": "^5.5.0",
"@tanem/react-nprogress": "^3.0.79",
"ace-builds": "^1.4.12",
"axios": "^0.21.4",
"@headlessui/react": "^1.5.0",
"@heroicons/react": "^1.0.6",
"@supercharge/request-ip": "^1.2.0",
"@svgr/webpack": "^6.2.1",
"@tanem/react-nprogress": "^4.0.10",
"ace-builds": "^1.4.14",
"axios": "^0.26.1",
"bcrypt": "^5.0.1",
"bowser": "^2.11.0",
"connect-typeorm": "^1.1.4",
"cookie-parser": "^1.4.5",
"cookie-parser": "^1.4.6",
"copy-to-clipboard": "^3.3.1",
"country-flag-icons": "^1.4.10",
"country-flag-icons": "^1.4.21",
"csurf": "^1.11.0",
"email-templates": "^8.0.8",
"express": "^4.17.1",
"express-openapi-validator": "^4.13.1",
"express-rate-limit": "^5.3.0",
"email-templates": "^8.0.10",
"express": "^4.17.3",
"express-openapi-validator": "^4.13.6",
"express-rate-limit": "^6.3.0",
"express-session": "^1.17.2",
"formik": "^2.2.9",
"gravatar-url": "3.1.0",
"gravatar-url": "^3.1.0",
"intl": "^1.2.5",
"lodash": "^4.17.21",
"next": "11.1.2",
"next": "12.1.0",
"node-cache": "^5.1.2",
"node-schedule": "^2.0.0",
"nodemailer": "^6.6.3",
"openpgp": "^5.0.0-3",
"plex-api": "^5.3.1",
"node-gyp": "^9.0.0",
"node-schedule": "^2.1.0",
"nodemailer": "^6.7.2",
"openpgp": "^5.2.0",
"plex-api": "^5.3.2",
"pug": "^3.0.2",
"react": "17.0.2",
"react-ace": "^9.3.0",
"react-ace": "^9.5.0",
"react-animate-height": "^2.0.23",
"react-dom": "17.0.2",
"react-intersection-observer": "^8.32.1",
"react-intl": "5.20.10",
"react-markdown": "^6.0.2",
"react-select": "^4.3.1",
"react-spring": "^9.2.4",
"react-intersection-observer": "^8.33.1",
"react-intl": "5.24.7",
"react-markdown": "^8.0.0",
"react-select": "^5.2.2",
"react-spring": "^9.4.4",
"react-toast-notifications": "^2.5.1",
"react-transition-group": "^4.4.2",
"react-truncate-markup": "^5.1.0",
"react-use-clipboard": "1.0.7",
"reflect-metadata": "^0.1.13",
"secure-random-password": "^0.2.3",
"semver": "^7.3.5",
"sqlite3": "^5.0.2",
"swagger-ui-express": "^4.1.6",
"swr": "^0.5.6",
"typeorm": "0.2.37",
"uuid": "^8.3.2",
"swagger-ui-express": "^4.3.0",
"swr": "^1.2.2",
"typeorm": "0.2.45",
"web-push": "^3.4.5",
"winston": "^3.3.3",
"winston-daily-rotate-file": "^4.5.5",
"winston": "^3.6.0",
"winston-daily-rotate-file": "^4.6.1",
"xml2js": "^0.4.23",
"yamljs": "^0.3.0",
"yup": "^0.32.9"
"yup": "^0.32.11"
},
"devDependencies": {
"@babel/cli": "^7.15.7",
"@commitlint/cli": "^13.1.0",
"@commitlint/config-conventional": "^13.1.0",
"@fullhuman/postcss-purgecss": "3.0.0",
"@semantic-release/changelog": "^5.0.1",
"@semantic-release/commit-analyzer": "^9.0.1",
"@semantic-release/exec": "^5.0.0",
"@semantic-release/git": "^9.0.1",
"@tailwindcss/aspect-ratio": "^0.2.1",
"@tailwindcss/forms": "^0.3.3",
"@tailwindcss/typography": "^0.4.1",
"@babel/cli": "^7.17.6",
"@commitlint/cli": "^16.2.1",
"@commitlint/config-conventional": "^16.2.1",
"@semantic-release/changelog": "^6.0.1",
"@semantic-release/commit-analyzer": "^9.0.2",
"@semantic-release/exec": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@tailwindcss/aspect-ratio": "^0.4.0",
"@tailwindcss/forms": "^0.5.0",
"@tailwindcss/typography": "^0.5.2",
"@types/bcrypt": "^5.0.0",
"@types/cookie-parser": "^1.4.2",
"@types/country-flag-icons": "^1.2.0",
"@types/csurf": "^1.11.2",
"@types/email-templates": "^8.0.4",
"@types/express": "^4.17.13",
"@types/express-rate-limit": "^5.1.3",
"@types/express-session": "^1.17.3",
"@types/lodash": "^4.14.173",
"@types/node": "^15.6.1",
"@types/express-session": "^1.17.4",
"@types/lodash": "^4.14.179",
"@types/node": "^17.0.21",
"@types/node-schedule": "^1.3.2",
"@types/nodemailer": "^6.4.4",
"@types/react": "^17.0.22",
"@types/react-dom": "^17.0.9",
"@types/react-select": "^4.0.17",
"@types/react-toast-notifications": "^2.4.1",
"@types/react-transition-group": "^4.4.3",
"@types/react": "^17.0.40",
"@types/react-dom": "^17.0.13",
"@types/react-transition-group": "^4.4.4",
"@types/secure-random-password": "^0.2.1",
"@types/semver": "^7.3.9",
"@types/swagger-ui-express": "^4.1.3",
"@types/uuid": "^8.3.1",
"@types/web-push": "^3.3.2",
"@types/xml2js": "^0.4.9",
"@types/yamljs": "^0.2.31",
"@types/yup": "^0.29.13",
"@typescript-eslint/eslint-plugin": "^4.31.1",
"@typescript-eslint/parser": "^4.31.1",
"autoprefixer": "^10.3.4",
"@typescript-eslint/eslint-plugin": "^5.14.0",
"@typescript-eslint/parser": "^5.14.0",
"autoprefixer": "^10.4.2",
"babel-plugin-react-intl": "^8.2.25",
"babel-plugin-react-intl-auto": "^3.3.0",
"commitizen": "^4.2.4",
"copyfiles": "^2.4.1",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^7.32.0",
"eslint-config-next": "^11.1.2",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-formatjs": "^2.17.6",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint": "^8.11.0",
"eslint-config-next": "^12.1.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-formatjs": "^3.0.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.25.3",
"eslint-plugin-react-hooks": "^4.2.0",
"eslint-plugin-react": "^7.29.3",
"eslint-plugin-react-hooks": "^4.3.0",
"extract-react-intl-messages": "^4.1.1",
"husky": "4.3.8",
"lint-staged": "^11.1.2",
"nodemon": "^2.0.12",
"postcss": "^8.3.6",
"prettier": "^2.4.1",
"semantic-release": "^18.0.0",
"husky": "^7.0.4",
"lint-staged": "^12.3.5",
"nodemon": "^2.0.15",
"postcss": "^8.4.8",
"prettier": "^2.5.1",
"prettier-plugin-tailwindcss": "^0.1.8",
"semantic-release": "^19.0.2",
"semantic-release-docker-buildx": "^1.0.1",
"tailwindcss": "^2.2.15",
"ts-node": "^10.2.1",
"typescript": "^4.4.3"
"tailwindcss": "^3.0.23",
"ts-node": "^10.7.0",
"typescript": "^4.6.2"
},
"resolutions": {
"sqlite3/node-gyp": "^5.1.0"
"sqlite3/node-gyp": "^8.4.1"
},
"config": {
"commitizen": {
"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": {
"**/*.{ts,tsx,js}": [
"prettier --write",
"eslint"
],
"**/*.{json,md}": [
"**/*.{json,md,css}": [
"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 http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#1f2937" />
<title>You are offline</title>

View File

@@ -90,8 +90,8 @@ self.addEventListener('push', (event) => {
if (payload.actionUrl){
options.actions.push(
{
action: 'viewmedia',
title: 'View Media',
action: 'view',
title: payload.actionUrlTitle ?? 'View',
}
);
}
@@ -119,21 +119,17 @@ self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'viewmedia') {
clients.openWindow(notificationData.actionUrl);
} else if (event.action === 'approve') {
if (event.action === 'approve') {
fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
method: 'POST',
});
clients.openWindow(notificationData.actionUrl);
} else if (event.action === 'decline') {
fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
method: 'POST',
});
}
clients.openWindow(notificationData.actionUrl);
} else if (notificationData.actionUrl) {
if (notificationData.actionUrl) {
clients.openWindow(notificationData.actionUrl);
}
}, false);

View File

@@ -83,7 +83,7 @@ class GithubAPI extends ExternalAPI {
} = {}): Promise<GitHubRelease[]> {
try {
const data = await this.get<GitHubRelease[]>(
'/repos/Fallenbagel/jellyseerr/releases',
'/repos/fallenbagel/jellyseerr/releases',
{
params: {
per_page: take,
@@ -110,7 +110,7 @@ class GithubAPI extends ExternalAPI {
} = {}): Promise<GithubCommit[]> {
try {
const data = await this.get<GithubCommit[]>(
'/repos/Fallenbagel/jellyseerr/commits',
'/repos/fallenbagel/jellyseerr/commits',
{
params: {
per_page: take,
@@ -122,7 +122,7 @@ class GithubAPI extends ExternalAPI {
return data;
} catch (e) {
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 }
);
return [];

View File

@@ -15,6 +15,10 @@ export interface JellyfinLoginResponse {
AccessToken: string;
}
export interface JellyfinUserListResponse {
users: Array<JellyfinUserResponse>;
}
export interface JellyfinLibrary {
type: 'show' | 'movie';
key: string;
@@ -81,9 +85,9 @@ class JellyfinAPI {
let authHeaderVal = '';
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 {
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({
@@ -122,7 +126,7 @@ class JellyfinAPI {
public async getServerName(): Promise<string> {
try {
const account = await this.axios.get<JellyfinUserResponse>(
`/System/Info/Public'}`
"/System/Info/Public'}"
);
return account.data.ServerName;
} 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> {
try {
const account = await this.axios.get<JellyfinUserResponse>(

View File

@@ -1,5 +1,6 @@
import NodePlexAPI from 'plex-api';
import { getSettings, Library, PlexSettings } from '../lib/settings';
import logger from '../logger';
export interface PlexLibraryItem {
ratingKey: string;
@@ -122,9 +123,9 @@ class PlexAPI {
// },
options: {
identifier: settings.clientId,
product: 'Jellyseerr',
deviceName: 'Jellyseerr',
platform: 'Jellyseerr',
product: 'Overseerr',
deviceName: 'Overseerr',
platform: 'Overseerr',
},
});
}
@@ -145,11 +146,14 @@ class PlexAPI {
public async syncLibraries(): Promise<void> {
const settings = getSettings();
try {
const libraries = await this.getLibraries();
const newLibraries: Library[] = libraries
// Remove libraries that are not movie or show
.filter((library) => library.type === 'movie' || library.type === 'show')
.filter(
(library) => library.type === 'movie' || library.type === 'show'
)
// Remove libraries that do not have a metadata agent set (usually personal video libraries)
.filter((library) => library.agent !== 'com.plexapp.agents.none')
.map((library) => {
@@ -167,6 +171,15 @@ class PlexAPI {
});
settings.plex.libraries = newLibraries;
} catch (e) {
logger.error('Failed to fetch Plex libraries', {
label: 'Plex API',
message: e.message,
});
settings.plex.libraries = [];
}
settings.save();
}

View File

@@ -224,7 +224,7 @@ class PlexTvAPI {
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) {
throw new Error(

View File

@@ -2,6 +2,35 @@ import cacheManager, { AvailableCacheIds } from '../../lib/cache';
import { DVRSettings } from '../../lib/settings';
import ExternalAPI from '../externalapi';
export interface SystemStatus {
version: string;
buildTime: Date;
isDebug: boolean;
isProduction: boolean;
isAdmin: boolean;
isUserInteractive: boolean;
startupPath: string;
appData: string;
osName: string;
osVersion: string;
isNetCore: boolean;
isMono: boolean;
isLinux: boolean;
isOsx: boolean;
isWindows: boolean;
isDocker: boolean;
mode: string;
branch: string;
authentication: string;
sqliteVersion: string;
migrationVersion: number;
urlBase: string;
runtimeVersion: string;
runtimeName: string;
startTime: Date;
packageUpdateMechanism: string;
}
export interface RootFolder {
id: number;
path: string;
@@ -81,6 +110,18 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
this.apiName = apiName;
}
public getSystemStatus = async (): Promise<SystemStatus> => {
try {
const response = await this.axios.get<SystemStatus>('/system/status');
return response.data;
} catch (e) {
throw new Error(
`[${this.apiName}] Failed to retrieve system status: ${e.message}`
);
}
};
public getProfiles = async (): Promise<QualityProfile[]> => {
try {
const data = await this.getRolling<QualityProfile[]>(

View File

@@ -1,7 +1,7 @@
import logger from '../../logger';
import ServarrBase from './base';
interface RadarrMovieOptions {
export interface RadarrMovieOptions {
title: string;
qualityProfileId: number;
minimumAvailability: string;
@@ -27,7 +27,6 @@ export interface RadarrMovie {
profileId: number;
qualityProfileId: number;
added: string;
downloaded: boolean;
hasFile: boolean;
}
@@ -85,7 +84,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
try {
const movie = await this.getMovieByTmdbId(options.tmdbId);
if (movie.downloaded) {
if (movie.hasFile) {
logger.info(
'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;
title: string;
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 (series.id) {
series.monitored = options.monitored ?? series.monitored;
series.tags = options.tags ?? series.tags;
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,
TmdbNetwork,
TmdbPersonCombinedCredits,
TmdbPersonDetail,
TmdbPersonDetails,
TmdbProductionCompany,
TmdbRegion,
TmdbSearchMovieResponse,
@@ -28,6 +28,10 @@ interface SearchOptions {
language?: string;
}
interface SingleSearchOptions extends SearchOptions {
year?: number;
}
interface DiscoverMovieOptions {
page?: number;
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 ({
personId,
language = 'en',
}: {
personId: number;
language?: string;
}): Promise<TmdbPersonDetail> => {
}): Promise<TmdbPersonDetails> => {
try {
const data = await this.get<TmdbPersonDetail>(`/person/${personId}`, {
const data = await this.get<TmdbPersonDetails>(`/person/${personId}`, {
params: { language },
});
@@ -561,13 +617,13 @@ class TheMovieDb extends ExternalAPI {
}
}
public async getMovieByImdbId({
public async getMediaByImdbId({
imdbId,
language = 'en',
}: {
imdbId: string;
language?: string;
}): Promise<TmdbMovieDetails> {
}): Promise<TmdbMovieDetails | TmdbTvDetails> {
try {
const extResponse = await this.getByExternalId({
externalId: imdbId,
@@ -583,12 +639,19 @@ class TheMovieDb extends ExternalAPI {
return movie;
}
throw new Error(
'[TMDb] Failed to find a title with the provided IMDB id'
);
if (extResponse.tv_results[0]) {
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) {
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 {
movie_results: TmdbMovieResult[];
tv_results: TmdbTvResult[];
person_results: TmdbPersonResult[];
}
export interface TmdbCreditCast {
@@ -251,6 +252,10 @@ export interface TmdbTvDetails {
name: string;
origin_country: string;
}[];
production_countries: {
iso_3166_1: string;
name: string;
}[];
spoken_languages: {
english_name: string;
iso_639_1: string;
@@ -311,7 +316,7 @@ export interface TmdbKeyword {
name: string;
}
export interface TmdbPersonDetail {
export interface TmdbPersonDetails {
id: number;
name: string;
birthday: string;
@@ -320,7 +325,7 @@ export interface TmdbPersonDetail {
also_known_as?: string[];
gender: number;
biography: string;
popularity: string;
popularity: number;
place_of_birth?: string;
profile_path?: string;
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 { getSettings } from '../lib/settings';
import logger from '../logger';
import Issue from './Issue';
import { MediaRequest } from './MediaRequest';
import Season from './Season';
@@ -55,7 +56,7 @@ class Media {
try {
const media = await mediaRepository.findOne({
where: { tmdbId: id, mediaType },
relations: ['requests'],
relations: ['requests', 'issues'],
});
return media;
@@ -98,6 +99,9 @@ class Media {
})
public seasons: Season[];
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
public issues: Issue[];
@CreateDateColumn()
public createdAt: Date;
@@ -148,27 +152,55 @@ class Media {
public mediaUrl?: string;
public mediaUrl4k?: string;
public tautulliUrl?: string;
public tautulliUrl4k?: string;
constructor(init?: Partial<Media>) {
Object.assign(this, init);
}
@AfterLoad()
public setMediaUrls(): void {
const settings = getSettings();
if (settings.main.mediaServerType == MediaServerType.PLEX) {
public setPlexUrls(): void {
const { machineId, webAppUrl } = getSettings().plex;
const { externalUrl: tautulliUrl } = getSettings().tautulli;
if (getSettings().main.mediaServerType == MediaServerType.PLEX) {
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) {
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 {
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) {
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) {
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,
UpdateDateColumn,
} from 'typeorm';
import RadarrAPI from '../api/servarr/radarr';
import SonarrAPI, { SonarrSeries } from '../api/servarr/sonarr';
import RadarrAPI, { RadarrMovieOptions } from '../api/servarr/radarr';
import SonarrAPI, {
AddSeriesOptions,
SonarrSeries,
} from '../api/servarr/sonarr';
import TheMovieDb from '../api/themoviedb';
import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants';
import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
@@ -135,51 +138,15 @@ export class MediaRequest {
where: { id: this.media.id },
});
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;
}
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) {
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,
});
}
this.sendNotification(media, Notification.MEDIA_PENDING);
}
}
@@ -200,74 +167,30 @@ export class MediaRequest {
where: { id: this.media.id },
});
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;
}
if (media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE) {
logger.warn(
'Media became available before request was approved. Approval notification will be skipped.',
{ label: 'Media Request' }
'Media became available before request was approved. Skipping approval notification',
{ label: 'Media Request', requestId: this.id, mediaId: this.media.id }
);
return;
}
const tmdb = new TheMovieDb();
if (this.media.mediaType === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
notificationManager.sendNotification(
this.sendNotification(
media,
this.status === MediaRequestStatus.APPROVED
? autoApproved
? Notification.MEDIA_AUTO_APPROVED
: 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,
}
: Notification.MEDIA_DECLINED
);
} 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'],
});
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;
}
const seasonRequestRepository = getRepository(SeasonRequest);
@@ -375,8 +302,12 @@ export class MediaRequest {
const settings = getSettings();
if (settings.radarr.length === 0 && !settings.radarr[0]) {
logger.info(
'Skipped Radarr request as there is no Radarr server configured',
{ label: 'Media Request' }
'No Radarr server configured, skipping request processing',
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
return;
}
@@ -395,18 +326,26 @@ export class MediaRequest {
);
logger.info(
`Request has an override server: ${radarrSettings?.name}`,
{ label: 'Media Request' }
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
}
if (!radarrSettings) {
logger.info(
logger.warn(
`There is no default ${
this.is4k ? '4K ' : ''
}Radarr server configured. Did you set any of your ${
this.is4k ? '4K ' : ''
}Radarr servers as default?`,
{ label: 'Media Request' }
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
return;
}
@@ -423,6 +362,8 @@ export class MediaRequest {
rootFolder = this.rootFolder;
logger.info(`Request has an override root folder: ${rootFolder}`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
});
}
@@ -431,15 +372,22 @@ export class MediaRequest {
this.profileId !== radarrSettings.activeProfileId
) {
qualityProfile = this.profileId;
logger.info(`Request has an override profile id: ${qualityProfile}`, {
logger.info(
`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)) {
tags = this.tags;
logger.info(`Request has override tags`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
tagIds: tags,
});
}
@@ -456,7 +404,11 @@ export class MediaRequest {
});
if (!media) {
logger.error('Media not present');
logger.error('Media data not found', {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
});
return;
}
@@ -466,9 +418,7 @@ export class MediaRequest {
throw new Error('Media already available');
}
// Run this asynchronously so we don't wait for it on the UI side
radarr
.addMovie({
const radarrMovieOptions: RadarrMovieOptions = {
profileId: qualityProfile,
qualityProfileId: qualityProfile,
rootFolderPath: rootFolder,
@@ -479,7 +429,11 @@ export class MediaRequest {
monitored: true,
tags,
searchNow: !radarrSettings.preventSearch,
})
};
// Run this asynchronously so we don't wait for it on the UI side
radarr
.addMovie(radarrMovieOptions)
.then(async (radarrMovie) => {
// We grab media again here to make sure we have the latest version of it
const media = await mediaRepository.findOne({
@@ -487,7 +441,7 @@ export class MediaRequest {
});
if (!media) {
throw new Error('Media data is missing');
throw new Error('Media data not found');
}
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
@@ -501,34 +455,30 @@ export class MediaRequest {
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
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',
requestId: this.id,
mediaId: this.media.id,
radarrMovieOptions,
}
);
notificationManager.sendNotification(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,
this.sendNotification(media, Notification.MEDIA_FAILED);
});
});
logger.info('Sent request to Radarr', { label: 'Media Request' });
} catch (e) {
const errorMessage = `Request failed to send to Radarr: ${e.message}`;
logger.error('Request failed to send to Radarr', {
logger.info('Sent request to Radarr', {
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 settings = getSettings();
if (settings.sonarr.length === 0 && !settings.sonarr[0]) {
logger.info(
'Skipped Sonarr request as there is no Sonarr server configured',
{ label: 'Media Request' }
logger.warn(
'No Sonarr server configured, skipping request processing',
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
return;
}
@@ -563,18 +517,26 @@ export class MediaRequest {
);
logger.info(
`Request has an override server: ${sonarrSettings?.name}`,
{ label: 'Media Request' }
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
}
if (!sonarrSettings) {
logger.info(
logger.warn(
`There is no default ${
this.is4k ? '4K ' : ''
}Sonarr server configured. Did you set any of your ${
this.is4k ? '4K ' : ''
}Sonarr servers as default?`,
{ label: 'Media Request' }
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
return;
}
@@ -585,7 +547,7 @@ export class MediaRequest {
});
if (!media) {
throw new Error('Media data is missing');
throw new Error('Media data not found');
}
if (
@@ -606,7 +568,7 @@ export class MediaRequest {
const requestRepository = getRepository(MediaRequest);
await mediaRepository.remove(media);
await requestRepository.remove(this);
throw new Error('Series was missing tvdb id');
throw new Error('TVDB ID not found');
}
let seriesType: SonarrSeries['seriesType'] = 'standard';
@@ -628,12 +590,10 @@ export class MediaRequest {
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
? sonarrSettings.activeAnimeProfileId
: sonarrSettings.activeProfileId;
let languageProfile =
seriesType === 'anime' && sonarrSettings.activeAnimeLanguageProfileId
? sonarrSettings.activeAnimeLanguageProfileId
: sonarrSettings.activeLanguageProfileId;
let tags =
seriesType === 'anime'
? sonarrSettings.animeTags
@@ -647,14 +607,21 @@ export class MediaRequest {
rootFolder = this.rootFolder;
logger.info(`Request has an override root folder: ${rootFolder}`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
});
}
if (this.profileId && this.profileId !== qualityProfile) {
qualityProfile = this.profileId;
logger.info(`Request has an override profile ID: ${qualityProfile}`, {
logger.info(
`Request has an override quality profile ID: ${qualityProfile}`,
{
label: 'Media Request',
});
requestId: this.id,
mediaId: this.media.id,
}
);
}
if (
@@ -663,9 +630,11 @@ export class MediaRequest {
) {
languageProfile = this.languageProfileId;
logger.info(
`Request has an override Language Profile: ${languageProfile}`,
`Request has an override language profile ID: ${languageProfile}`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
}
@@ -674,13 +643,13 @@ export class MediaRequest {
tags = this.tags;
logger.info(`Request has override tags`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
tagIds: tags,
});
}
// Run this asynchronously so we don't wait for it on the UI side
sonarr
.addSeries({
const sonarrSeriesOptions: AddSeriesOptions = {
profileId: qualityProfile,
languageProfileId: languageProfile,
rootFolderPath: rootFolder,
@@ -692,7 +661,11 @@ export class MediaRequest {
tags,
monitored: true,
searchNow: !sonarrSettings.preventSearch,
})
};
// Run this asynchronously so we don't wait for it on the UI side
sonarr
.addSeries(sonarrSeriesOptions)
.then(async (sonarrSeries) => {
// We grab media again here to make sure we have the latest version of it
const media = await mediaRepository.findOne({
@@ -701,7 +674,7 @@ export class MediaRequest {
});
if (!media) {
throw new Error('Media data is missing');
throw new Error('Media data not found');
}
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
@@ -715,45 +688,116 @@ export class MediaRequest {
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
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',
requestId: this.id,
mediaId: this.media.id,
sonarrSeriesOptions,
}
);
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
subject: `${series.name}${
series.first_air_date
? ` (${series.first_air_date.slice(0, 4)})`
: ''
this.sendNotification(media, Notification.MEDIA_FAILED);
});
logger.info('Sent request to Sonarr', {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
});
} 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(series.overview, {
message: truncate(movie.overview, {
length: 500,
separator: /\s/,
omission: '…',
}),
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${series.poster_path}`,
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: 'Seasons',
name: 'Requested Seasons',
value: this.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
request: this,
});
});
logger.info('Sent request to Sonarr', { label: 'Media Request' });
} catch (e) {
const errorMessage = `Request failed to send to Sonarr: ${e.message}`;
logger.error('Request failed to send to Sonarr', {
label: 'Media Request',
errorMessage,
});
throw new Error(errorMessage);
}
} 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';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import Issue from './Issue';
import { MediaRequest } from './MediaRequest';
import SeasonRequest from './SeasonRequest';
import { UserPushSubscription } from './UserPushSubscription';
@@ -61,7 +62,7 @@ export class User {
public plexUsername?: string;
@Column({ nullable: true })
public jellyfinUsername: string;
public jellyfinUsername?: string;
@Column({ nullable: true })
public username?: string;
@@ -127,6 +128,9 @@ export class User {
@OneToMany(() => UserPushSubscription, (pushSub) => pushSub.user)
public pushSubscriptions: UserPushSubscription[];
@OneToMany(() => Issue, (issue) => issue.createdBy, { cascade: true })
public createdIssues: Issue[];
@CreateDateColumn()
public createdAt: Date;
@@ -190,6 +194,7 @@ export class User {
password: password,
applicationUrl,
applicationTitle,
recipientName: this.username,
},
});
} catch (e) {
@@ -226,6 +231,8 @@ export class User {
resetPasswordLink,
applicationUrl,
applicationTitle,
recipientName: this.displayName,
recipientEmail: this.email,
},
});
} catch (e) {
@@ -239,8 +246,7 @@ export class User {
@AfterLoad()
public setDisplayName(): void {
this.displayName =
this.username || this.plexUsername || this.jellyfinUsername;
this.displayName = this.username || this.plexUsername || this.email;
this.username || this.plexUsername || this.jellyfinUsername || this.email;
}
public async getQuota(): Promise<QuotaResponse> {

View File

@@ -42,6 +42,15 @@ export class UserSettings {
@Column({ nullable: true })
public discordId?: string;
@Column({ nullable: true })
public pushbulletAccessToken?: string;
@Column({ nullable: true })
public pushoverApplicationToken?: string;
@Column({ nullable: true })
public pushoverUserKey?: string;
@Column({ nullable: true })
public telegramChatId?: string;

View File

@@ -17,6 +17,7 @@ import { startJobs } from './job/schedule';
import notificationManager from './lib/notifications';
import DiscordAgent from './lib/notifications/agents/discord';
import EmailAgent from './lib/notifications/agents/email';
import GotifyAgent from './lib/notifications/agents/gotify';
import LunaSeaAgent from './lib/notifications/agents/lunasea';
import PushbulletAgent from './lib/notifications/agents/pushbullet';
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');
logger.info(`Starting Jellyseerr version ${getAppVersion()}`);
logger.info(`Starting Overseerr version ${getAppVersion()}`);
const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();
@@ -63,11 +64,12 @@ app
});
if (admin) {
const plexapi = new PlexAPI({ plexToken: admin.plexToken });
await plexapi.syncLibraries();
logger.info('Migrating libraries to include media type', {
logger.info('Migrating Plex libraries to include media type', {
label: 'Settings',
});
const plexapi = new PlexAPI({ plexToken: admin.plexToken });
await plexapi.syncLibraries();
}
}
@@ -75,6 +77,7 @@ app
notificationManager.registerAgents([
new DiscordAgent(),
new EmailAgent(),
new GotifyAgent(),
new LunaSeaAgent(),
new PushbulletAgent(),
new PushoverAgent(),
@@ -138,6 +141,9 @@ app
saveUninitialized: false,
cookie: {
maxAge: 1000 * 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: true,
secure: 'auto',
},
store: new TypeormStore({
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 { User } from '../../entity/User';
import { PaginatedResponse } from './common';
export interface MediaResultsResponse extends PaginatedResponse {
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 = {
timestamp: string;
level: string;
label: string;
label?: string;
message: string;
data?: Record<string, unknown>;
};
@@ -17,6 +17,7 @@ export interface SettingsAboutResponse {
totalRequests: number;
totalMediaItems: number;
tz?: string;
appDataPath: string;
}
export interface PublicSettingsResponse {
@@ -38,6 +39,7 @@ export interface PublicSettingsResponse {
enablePushRegistration: boolean;
locale: string;
emailEnabled: boolean;
newPlexLogin: boolean;
}
export interface CacheItem {

View File

@@ -1,3 +1,4 @@
import Media from '../../entity/Media';
import { MediaRequest } from '../../entity/MediaRequest';
import type { User } from '../../entity/User';
import { PaginatedResponse } from './common';
@@ -22,3 +23,7 @@ export interface QuotaResponse {
movie: QuotaStatus;
tv: QuotaStatus;
}
export interface UserWatchDataResponse {
recentlyWatched: Media[];
playCount: number;
}

View File

@@ -2,6 +2,7 @@ import { NotificationAgentKey } from '../../lib/settings';
export interface UserSettingsGeneralResponse {
username?: string;
discordId?: string;
locale?: string;
region?: string;
originalLanguage?: string;
@@ -22,6 +23,9 @@ export interface UserSettingsNotificationsResponse {
discordEnabled?: boolean;
discordEnabledTypes?: number;
discordId?: string;
pushbulletAccessToken?: string;
pushoverApplicationToken?: string;
pushoverUserKey?: string;
telegramEnabled?: boolean;
telegramBotUsername?: string;
telegramChatId?: string;

View File

@@ -71,7 +71,7 @@ class JobJellyfinSync {
newMedia.tmdbId = Number(metadata.ProviderIds.Tmdb ?? null);
newMedia.imdbId = metadata.ProviderIds.Imdb;
if (newMedia.imdbId && !isNaN(newMedia.tmdbId)) {
const tmdbMovie = await this.tmdb.getMovieByImdbId({
const tmdbMovie = await this.tmdb.getMediaByImdbId({
imdbId: newMedia.imdbId,
});
newMedia.tmdbId = tmdbMovie.id;

View File

@@ -1,16 +1,19 @@
import schedule from 'node-schedule';
import { MediaServerType } from '../constants/server';
import downloadTracker from '../lib/downloadtracker';
import { plexFullScanner, plexRecentScanner } from '../lib/scanners/plex';
import { radarrScanner } from '../lib/scanners/radarr';
import { sonarrScanner } from '../lib/scanners/sonarr';
import { getSettings, JobId } from '../lib/settings';
import logger from '../logger';
import { jobJellyfinFullSync, jobJellyfinRecentSync } from './jellyfinsync';
interface ScheduledJob {
id: string;
id: JobId;
job: schedule.Job;
name: string;
type: 'process' | 'command';
interval: 'short' | 'long' | 'fixed';
running?: () => boolean;
cancelFn?: () => void;
}
@@ -18,17 +21,25 @@ interface ScheduledJob {
export const scheduledJobs: ScheduledJob[] = [];
export const startJobs = (): void => {
const jobs = getSettings().jobs;
const mediaServerType = getSettings().main.mediaServerType;
if (mediaServerType === MediaServerType.PLEX) {
// Run recently added plex scan every 5 minutes
scheduledJobs.push({
id: 'plex-recently-added-scan',
name: 'Plex Recently Added Scan',
type: 'process',
job: schedule.scheduleJob('0 */5 * * * *', () => {
interval: 'short',
job: schedule.scheduleJob(
jobs['plex-recently-added-scan'].schedule,
() => {
logger.info('Starting scheduled job: Plex Recently Added Scan', {
label: 'Jobs',
});
plexRecentScanner.run();
}),
}
),
running: () => plexRecentScanner.status().running,
cancelFn: () => plexRecentScanner.cancel(),
});
@@ -38,7 +49,8 @@ export const startJobs = (): void => {
id: 'plex-full-scan',
name: 'Plex Full Library Scan',
type: 'process',
job: schedule.scheduleJob('0 0 3 * * *', () => {
interval: 'long',
job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => {
logger.info('Starting scheduled job: Plex Full Library Scan', {
label: 'Jobs',
});
@@ -47,18 +59,25 @@ export const startJobs = (): void => {
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',
job: schedule.scheduleJob('0 */5 * * * *', () => {
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(),
});
@@ -68,7 +87,8 @@ export const startJobs = (): void => {
id: 'jellyfin-full-sync',
name: 'Jellyfin Full Library Sync',
type: 'process',
job: schedule.scheduleJob('0 0 3 * * *', () => {
interval: 'long',
job: schedule.scheduleJob(jobs['jellyfin-full-sync'].schedule, () => {
logger.info('Starting scheduled job: Jellyfin Full Sync', {
label: 'Jobs',
});
@@ -77,13 +97,15 @@ export const startJobs = (): void => {
running: () => jobJellyfinFullSync.status().running,
cancelFn: () => jobJellyfinFullSync.cancel(),
});
}
// Run full radarr scan every 24 hours
scheduledJobs.push({
id: 'radarr-scan',
name: 'Radarr Scan',
type: 'process',
job: schedule.scheduleJob('0 0 4 * * *', () => {
interval: 'long',
job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => {
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
radarrScanner.run();
}),
@@ -96,7 +118,8 @@ export const startJobs = (): void => {
id: 'sonarr-scan',
name: 'Sonarr Scan',
type: 'process',
job: schedule.scheduleJob('0 30 4 * * *', () => {
interval: 'long',
job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => {
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
sonarrScanner.run();
}),
@@ -104,23 +127,27 @@ export const startJobs = (): void => {
cancelFn: () => sonarrScanner.cancel(),
});
// Run download sync
// Run download sync every minute
scheduledJobs.push({
id: 'download-sync',
name: 'Download Sync',
type: 'command',
job: schedule.scheduleJob('0 * * * * *', () => {
logger.debug('Starting scheduled job: Download Sync', { label: 'Jobs' });
interval: 'fixed',
job: schedule.scheduleJob(jobs['download-sync'].schedule, () => {
logger.debug('Starting scheduled job: Download Sync', {
label: 'Jobs',
});
downloadTracker.updateDownloads();
}),
});
// Reset download sync
// Reset download sync everyday at 01:00 am
scheduledJobs.push({
id: 'download-sync-reset',
name: 'Download Sync Reset',
type: 'command',
job: schedule.scheduleJob('0 0 1 * * *', () => {
interval: 'long',
job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => {
logger.info('Starting scheduled job: Download Sync Reset', {
label: 'Jobs',
});

View File

@@ -40,7 +40,7 @@ class Cache {
class CacheManager {
private availableCaches: Record<AvailableCacheIds, Cache> = {
tmdb: new Cache('tmdb', 'TMDb API', {
tmdb: new Cache('tmdb', 'The Movie Database API', {
stdTtl: 21600,
checkPeriod: 60 * 30,
}),
@@ -54,7 +54,7 @@ class CacheManager {
stdTtl: 21600,
checkPeriod: 60 * 30,
}),
plexguid: new Cache('plexguid', 'Plex GUID Cache', {
plexguid: new Cache('plexguid', 'Plex GUID', {
stdTtl: 86400 * 7, // 1 week cache
checkPeriod: 60 * 30,
}),

View File

@@ -76,6 +76,7 @@ class DownloadTracker {
url: RadarrAPI.buildUrl(server, '/api/v3'),
});
try {
const queueItems = await radarr.getQueue();
this.radarrServers[server.id] = queueItems.map((item) => ({
@@ -95,6 +96,14 @@ class DownloadTracker {
{ label: 'Download Tracker' }
);
}
} catch {
logger.error(
`Unable to get queue from Radarr server: ${server.name}`,
{
label: 'Download Tracker',
}
);
}
// Duplicate this data to matching servers
const matchingServers = settings.radarr.filter(
@@ -134,16 +143,17 @@ class DownloadTracker {
);
});
// Load downloads from Radarr servers
// Load downloads from Sonarr servers
Promise.all(
filteredServers.map(async (server) => {
if (server.syncEnabled) {
const radarr = new SonarrAPI({
const sonarr = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildUrl(server, '/api/v3'),
});
const queueItems = await radarr.getQueue();
try {
const queueItems = await sonarr.getQueue();
this.sonarrServers[server.id] = queueItems.map((item) => ({
externalId: item.seriesId,
@@ -162,14 +172,22 @@ class DownloadTracker {
{ label: 'Download Tracker' }
);
}
} catch {
logger.error(
`Unable to get queue from Sonarr server: ${server.name}`,
{
label: 'Download Tracker',
}
);
}
// Duplicate this data to matching servers
const matchingServers = settings.sonarr.filter(
(rs) =>
rs.hostname === server.hostname &&
rs.port === server.port &&
rs.baseUrl === server.baseUrl &&
rs.id !== server.id
(ss) =>
ss.hostname === server.hostname &&
ss.port === server.port &&
ss.baseUrl === server.baseUrl &&
ss.id !== server.id
);
if (matchingServers.length > 0) {

View File

@@ -1,7 +1,7 @@
import { randomBytes } from 'crypto';
import MailMessage from 'nodemailer/lib/mailer/mail-message';
import * as openpgp from 'openpgp';
import { Transform, TransformCallback } from 'stream';
import logger from '../../logger';
interface EncryptorOptions {
signingKey?: string;
@@ -26,7 +26,7 @@ class PGPEncryptor extends Transform {
// just save the whole message
_transform = (
chunk: Uint8Array,
chunk: any,
_encoding: BufferEncoding,
callback: TransformCallback
): void => {
@@ -37,10 +37,14 @@ class PGPEncryptor extends Transform {
// Actually do stuff
_flush = async (callback: TransformCallback): Promise<void> => {
// Reconstruct message as buffer
const message = Buffer.concat(this._messageChunks, this._messageLength);
try {
// Reconstruct message as buffer
const validPublicKeys = await Promise.all(
this._encryptionKeys.map((armoredKey) => openpgp.readKey({ armoredKey }))
this._encryptionKeys.map((armoredKey) =>
openpgp.readKey({ armoredKey })
)
);
let privateKey: openpgp.PrivateKey | undefined;
@@ -52,11 +56,12 @@ class PGPEncryptor extends Transform {
// Only sign the message if private key and password exist
if (this._signingKey && this._password) {
privateKey = await openpgp.readPrivateKey({
privateKey = await openpgp.decryptKey({
privateKey: await openpgp.readPrivateKey({
armoredKey: this._signingKey,
}),
passphrase: this._password,
});
await openpgp.decryptKey({ privateKey, passphrase: this._password });
}
const emailPartDelimiter = '\r\n\r\n';
@@ -163,15 +168,28 @@ class PGPEncryptor extends Transform {
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();
}
};
}
export const openpgpEncrypt = (options: EncryptorOptions) => {
return function (mail: MailMessage, callback: () => unknown): void {
return function (mail: any, callback: () => unknown): void {
if (!options.encryptionKeys.length) {
setImmediate(callback);
}
mail.message.transform(
() =>
new PGPEncryptor({
signingKey: options.signingKey,
password: options.password,

View File

@@ -1,17 +1,23 @@
import { Notification } from '..';
import type Issue from '../../../entity/Issue';
import IssueComment from '../../../entity/IssueComment';
import Media from '../../../entity/Media';
import { MediaRequest } from '../../../entity/MediaRequest';
import { User } from '../../../entity/User';
import { NotificationAgentConfig } from '../../settings';
export interface NotificationPayload {
event?: string;
subject: string;
notifyAdmin: boolean;
notifyUser?: User;
media?: Media;
image?: string;
message?: string;
extra?: { name: string; value: string }[];
request?: MediaRequest;
issue?: Issue;
comment?: IssueComment;
}
export abstract class BaseAgent<T extends NotificationAgentConfig> {

View File

@@ -1,9 +1,13 @@
import axios from 'axios';
import { getRepository } from 'typeorm';
import { hasNotificationType, Notification } from '..';
import {
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import { Permission } from '../../permissions';
import {
getSettings,
NotificationAgentDiscord,
@@ -91,7 +95,8 @@ interface DiscordWebhookPayload {
class DiscordAgent
extends BaseAgent<NotificationAgentDiscord>
implements NotificationAgent {
implements NotificationAgent
{
protected getSettings(): NotificationAgentDiscord {
if (this.settings) {
return this.settings;
@@ -106,9 +111,9 @@ class DiscordAgent
type: Notification,
payload: NotificationPayload
): DiscordRichEmbed {
const settings = getSettings();
let color = EmbedColors.DARK_PURPLE;
const { applicationUrl } = getSettings().main;
let color = EmbedColors.DARK_PURPLE;
const fields: Field[] = [];
if (payload.request) {
@@ -117,55 +122,93 @@ class DiscordAgent
value: payload.request.requestedBy.displayName,
inline: true,
});
}
let status = '';
switch (type) {
case Notification.MEDIA_PENDING:
color = EmbedColors.ORANGE;
fields.push({
name: 'Status',
value: 'Pending Approval',
inline: true,
});
status = 'Pending Approval';
break;
case Notification.MEDIA_APPROVED:
case Notification.MEDIA_AUTO_APPROVED:
color = EmbedColors.PURPLE;
fields.push({
name: 'Status',
value: 'Processing',
inline: true,
});
status = 'Processing';
break;
case Notification.MEDIA_AVAILABLE:
color = EmbedColors.GREEN;
fields.push({
name: 'Status',
value: 'Available',
inline: true,
});
status = 'Available';
break;
case Notification.MEDIA_DECLINED:
color = EmbedColors.RED;
fields.push({
name: 'Status',
value: 'Declined',
inline: true,
});
status = 'Declined';
break;
case Notification.MEDIA_FAILED:
color = EmbedColors.RED;
fields.push({
name: 'Status',
value: 'Failed',
inline: true,
});
status = 'Failed';
break;
}
const url =
settings.main.applicationUrl && payload.media
? `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
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;
}
}
for (const extra of payload.extra ?? []) {
fields.push({
name: extra.name,
value: extra.value,
inline: true,
});
}
const url = applicationUrl
? payload.issue
? `${applicationUrl}/issues/${payload.issue.id}`
: payload.media
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
: undefined;
return {
@@ -174,18 +217,12 @@ class DiscordAgent
description: payload.message,
color,
timestamp: new Date().toISOString(),
author: {
name: settings.main.applicationTitle,
url: settings.main.applicationUrl,
},
fields: [
...fields,
// If we have extra data, map it to fields for discord notifications
...(payload.extra ?? []).map((extra) => ({
name: extra.name,
value: extra.value,
})),
],
author: payload.event
? {
name: payload.event,
}
: undefined,
fields,
thumbnail: {
url: payload.image,
},
@@ -218,54 +255,55 @@ class DiscordAgent
subject: payload.subject,
});
let content = undefined;
const userMentions: string[] = [];
try {
if (settings.options.enableMentions) {
if (payload.notifyUser) {
// Mention user who submitted the request
if (
payload.notifyUser.settings?.hasNotificationType(
NotificationAgentKey.DISCORD,
type
) &&
payload.notifyUser.settings?.discordId
payload.notifyUser.settings.discordId
) {
content = `<@${payload.notifyUser.settings.discordId}>`;
userMentions.push(`<@${payload.notifyUser.settings.discordId}>`);
}
} else {
// Mention all users with the Manage Requests permission
}
if (payload.notifyAdmin) {
const userRepository = getRepository(User);
const users = await userRepository.find();
content = users
userMentions.push(
...users
.filter(
(user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
user.settings?.hasNotificationType(
NotificationAgentKey.DISCORD,
type
) &&
user.settings?.discordId &&
// Check if it's the user's own auto-approved request
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
user.settings.discordId &&
shouldSendAdminNotification(type, user, payload)
)
.map((user) => `<@${user.settings?.discordId}>`)
.join(' ');
);
}
}
await axios.post(settings.options.webhookUrl, {
username: settings.options.botUsername,
username: settings.options.botUsername
? settings.options.botUsername
: getSettings().main.applicationTitle,
avatar_url: settings.options.botAvatarUrl,
embeds: [this.buildEmbed(type, payload)],
content,
content: userMentions.join(' '),
} as DiscordWebhookPayload);
return true;
} catch (e) {
logger.error('Error sending Discord notification', {
label: 'Notifications',
mentions: content,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,

View File

@@ -1,12 +1,12 @@
import { EmailOptions } from 'email-templates';
import path from 'path';
import { getRepository } from 'typeorm';
import { Notification } from '..';
import { Notification, shouldSendAdminNotification } from '..';
import { IssueType, IssueTypeName } from '../../../constants/issue';
import { MediaType } from '../../../constants/media';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import PreparedEmail from '../../email';
import { Permission } from '../../permissions';
import {
getSettings,
NotificationAgentEmail,
@@ -16,7 +16,8 @@ import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
class EmailAgent
extends BaseAgent<NotificationAgentEmail>
implements NotificationAgent {
implements NotificationAgent
{
protected getSettings(): NotificationAgentEmail {
if (this.settings) {
return this.settings;
@@ -45,7 +46,8 @@ class EmailAgent
private buildMessage(
type: Notification,
payload: NotificationPayload,
toEmail: string
recipientEmail: string,
recipientName?: string
): EmailOptions | undefined {
const { applicationUrl, applicationTitle } = getSettings().main;
@@ -53,69 +55,59 @@ class EmailAgent
return {
template: path.join(__dirname, '../../../templates/email/test-email'),
message: {
to: toEmail,
to: recipientEmail,
},
locals: {
body: payload.message,
applicationUrl,
applicationTitle,
recipientName,
recipientEmail,
},
};
}
if (payload.media) {
let requestType = '';
const mediaType = payload.media
? payload.media.mediaType === MediaType.MOVIE
? 'movie'
: 'series'
: undefined;
const is4k = payload.request?.is4k;
if (payload.request) {
let body = '';
switch (type) {
case Notification.MEDIA_PENDING:
requestType = `New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
body = `A user has requested a new ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
}!`;
body = `A new request for the following ${mediaType} ${
is4k ? 'in 4K ' : ''
}is pending approval:`;
break;
case Notification.MEDIA_APPROVED:
requestType = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved`;
body = `Your request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} has been approved:`;
body = `Your request for the following ${mediaType} ${
is4k ? 'in 4K ' : ''
}has been approved:`;
break;
case Notification.MEDIA_AUTO_APPROVED:
requestType = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved`;
body = `A new request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} has been automatically approved:`;
body = `A new request for the following ${mediaType} ${
is4k ? 'in 4K ' : ''
}has been automatically approved:`;
break;
case Notification.MEDIA_AVAILABLE:
requestType = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available`;
body = `The following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} you requested is now available!`;
body = `Your request for the following ${mediaType} ${
is4k ? 'in 4K ' : ''
}is now available:`;
break;
case Notification.MEDIA_DECLINED:
requestType = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined`;
body = `Your request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} was declined:`;
body = `Your request for the following ${mediaType} ${
is4k ? 'in 4K ' : ''
}was declined:`;
break;
case Notification.MEDIA_FAILED:
requestType = `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
body = `A new request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} could not be added to ${
payload.media?.mediaType === MediaType.TV ? 'Sonarr' : 'Radarr'
body = `A request for the following ${mediaType} ${
is4k ? 'in 4K ' : ''
}failed to be added to ${
payload.media?.mediaType === MediaType.MOVIE ? 'Radarr' : 'Sonarr'
}:`;
break;
}
@@ -126,22 +118,69 @@ class EmailAgent
'../../../templates/email/media-request'
),
message: {
to: toEmail,
to: recipientEmail,
},
locals: {
requestType,
event: payload.event,
body,
mediaName: payload.subject,
mediaPlot: payload.message,
mediaExtra: payload.extra ?? [],
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.request?.requestedBy.displayName,
requestedBy: payload.request.requestedBy.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
recipientName,
recipientEmail,
},
};
} else if (payload.issue) {
const issueType =
payload.issue && payload.issue.issueType !== IssueType.OTHER
? `${IssueTypeName[payload.issue.issueType].toLowerCase()} issue`
: 'issue';
let body = '';
switch (type) {
case Notification.ISSUE_CREATED:
body = `A new ${issueType} has been reported by ${payload.issue.createdBy.displayName} for the ${mediaType} ${payload.subject}:`;
break;
case Notification.ISSUE_COMMENT:
body = `${payload.comment?.user.displayName} commented on the ${issueType} for the ${mediaType} ${payload.subject}:`;
break;
case Notification.ISSUE_RESOLVED:
body = `The ${issueType} for the ${mediaType} ${payload.subject} was marked as resolved by ${payload.issue.modifiedBy?.displayName}!`;
break;
case Notification.ISSUE_REOPENED:
body = `The ${issueType} for the ${mediaType} ${payload.subject} was reopened by ${payload.issue.modifiedBy?.displayName}.`;
break;
}
return {
template: path.join(__dirname, '../../../templates/email/media-issue'),
message: {
to: recipientEmail,
},
locals: {
event: payload.event,
body,
issueDescription: payload.message,
issueComment: payload.comment?.message,
mediaName: payload.subject,
extra: payload.extra ?? [],
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
actionUrl: applicationUrl
? `${applicationUrl}/issues/${payload.issue.id}`
: undefined,
applicationUrl,
applicationTitle,
recipientName,
recipientEmail,
},
};
}
@@ -154,7 +193,6 @@ class EmailAgent
payload: NotificationPayload
): Promise<boolean> {
if (payload.notifyUser) {
// Send notification to the user who submitted the request
if (
!payload.notifyUser.settings ||
// Check if user has email notifications enabled and fallback to true if undefined
@@ -178,7 +216,12 @@ class EmailAgent
payload.notifyUser.settings?.pgpKey
);
await email.send(
this.buildMessage(type, payload, payload.notifyUser.email)
this.buildMessage(
type,
payload,
payload.notifyUser.email,
payload.notifyUser.displayName
)
);
} catch (e) {
logger.error('Error sending email notification', {
@@ -192,8 +235,9 @@ class EmailAgent
return false;
}
}
} else {
// Send notifications to all users with the Manage Requests permission
}
if (payload.notifyAdmin) {
const userRepository = getRepository(User);
const users = await userRepository.find();
@@ -201,7 +245,6 @@ class EmailAgent
users
.filter(
(user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
(!user.settings ||
// Check if user has email notifications enabled and fallback to true if undefined
// since email should default to true
@@ -210,9 +253,7 @@ class EmailAgent
type
) ??
true)) &&
// Check if it's the user's own auto-approved request
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
shouldSendAdminNotification(type, user, payload)
)
.map(async (user) => {
logger.debug('Sending email notification', {
@@ -227,7 +268,9 @@ class EmailAgent
this.getSettings(),
user.settings?.pgpKey
);
await email.send(this.buildMessage(type, payload, user.email));
await email.send(
this.buildMessage(type, payload, user.email, user.displayName)
);
} catch (e) {
logger.error('Error sending email notification', {
label: 'Notifications',

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 { hasNotificationType, Notification } from '..';
import { IssueStatus, IssueType } from '../../../constants/issue';
import { MediaStatus } from '../../../constants/media';
import logger from '../../../logger';
import { getSettings, NotificationAgentLunaSea } from '../../settings';
@@ -7,7 +8,8 @@ import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
class LunaSeaAgent
extends BaseAgent<NotificationAgentLunaSea>
implements NotificationAgent {
implements NotificationAgent
{
protected getSettings(): NotificationAgentLunaSea {
if (this.settings) {
return this.settings;
@@ -21,17 +23,17 @@ class LunaSeaAgent
private buildPayload(type: Notification, payload: NotificationPayload) {
return {
notification_type: Notification[type],
event: payload.event,
subject: payload.subject,
message: payload.message,
image: payload.image ?? null,
email: payload.notifyUser?.email,
username: payload.notifyUser?.username,
username: payload.notifyUser?.displayName,
avatar: payload.notifyUser?.avatar,
media: payload.media
? {
media_type: payload.media.mediaType,
tmdbId: payload.media.tmdbId,
imdbId: payload.media.imdbId,
tvdbId: payload.media.tvdbId,
status: MediaStatus[payload.media.status],
status4k: MediaStatus[payload.media.status4k],
@@ -46,6 +48,24 @@ class LunaSeaAgent
requestedBy_avatar: payload.request.requestedBy.avatar,
}
: null,
issue: payload.issue
? {
issue_id: payload.issue.id,
issue_type: IssueType[payload.issue.issueType],
issue_status: IssueStatus[payload.issue.status],
createdBy_email: payload.issue.createdBy.email,
createdBy_username: payload.issue.createdBy.displayName,
createdBy_avatar: payload.issue.createdBy.avatar,
}
: null,
comment: payload.comment
? {
comment_message: payload.comment.message,
commentedBy_email: payload.comment.user.email,
commentedBy_username: payload.comment.user.displayName,
commentedBy_avatar: payload.comment.user.avatar,
}
: null,
};
}

View File

@@ -1,18 +1,31 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import { MediaType } from '../../../constants/media';
import { getRepository } from 'typeorm';
import {
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import { getSettings, NotificationAgentPushbullet } from '../../settings';
import {
getSettings,
NotificationAgentKey,
NotificationAgentPushbullet,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface PushbulletPayload {
type: string;
title: string;
body: string;
channel_tag?: string;
}
class PushbulletAgent
extends BaseAgent<NotificationAgentPushbullet>
implements NotificationAgent {
implements NotificationAgent
{
protected getSettings(): NotificationAgentPushbullet {
if (this.settings) {
return this.settings;
@@ -24,109 +37,62 @@ class PushbulletAgent
}
public shouldSend(): boolean {
const settings = this.getSettings();
if (settings.enabled && settings.options.accessToken) {
return true;
}
return false;
}
private constructMessageDetails(
private getNotificationPayload(
type: Notification,
payload: NotificationPayload
): {
title: string;
body: string;
} {
let messageTitle = '';
let message = '';
): PushbulletPayload {
const title = payload.event
? `${payload.event} - ${payload.subject}`
: payload.subject;
let body = payload.message ?? '';
const title = payload.subject;
const plot = payload.message;
const username = payload.request?.requestedBy.displayName;
if (payload.request) {
body += `\n\nRequested By: ${payload.request.requestedBy.displayName}`;
let status = '';
switch (type) {
case Notification.MEDIA_PENDING:
messageTitle = `New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Pending Approval`;
status = 'Pending Approval';
break;
case Notification.MEDIA_APPROVED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Processing`;
break;
case Notification.MEDIA_AUTO_APPROVED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Processing`;
status = '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`;
status = '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`;
status = 'Declined';
break;
case Notification.MEDIA_FAILED:
messageTitle = `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
status = 'Failed';
break;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Failed`;
break;
case Notification.TEST_NOTIFICATION:
messageTitle = 'Test Notification';
message += `${plot}`;
break;
if (status) {
body += `\nRequest Status: ${status}`;
}
} else if (payload.comment) {
body += `\n\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message}`;
} else if (payload.issue) {
body += `\n\nReported By: ${payload.issue.createdBy.displayName}`;
body += `\nIssue Type: ${IssueTypeName[payload.issue.issueType]}`;
body += `\nIssue Status: ${
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
}`;
}
for (const extra of payload.extra ?? []) {
message += `\n${extra.name}: ${extra.value}`;
body += `\n${extra.name}: ${extra.value}`;
}
return {
title: messageTitle,
body: message,
type: 'note',
title,
body,
};
}
@@ -135,11 +101,15 @@ class PushbulletAgent
payload: NotificationPayload
): Promise<boolean> {
const settings = this.getSettings();
const endpoint = 'https://api.pushbullet.com/v2/pushes';
const notificationPayload = this.getNotificationPayload(type, payload);
if (!hasNotificationType(type, settings.types ?? 0)) {
return true;
}
// Send system notification
if (
hasNotificationType(type, settings.types ?? 0) &&
settings.enabled &&
settings.options.accessToken
) {
logger.debug('Sending Pushbullet notification', {
label: 'Notifications',
type: Notification[type],
@@ -147,23 +117,15 @@ class PushbulletAgent
});
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,
endpoint,
{ ...notificationPayload, channel_tag: settings.options.channelTag },
{
headers: {
'Access-Token': settings.options.accessToken,
},
}
);
return true;
} catch (e) {
logger.error('Error sending Pushbullet notification', {
label: 'Notifications',
@@ -176,6 +138,97 @@ class PushbulletAgent
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;
}
}
export default PushbulletAgent;

View File

@@ -1,8 +1,18 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import { MediaType } from '../../../constants/media';
import { getRepository } from 'typeorm';
import {
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import { getSettings, NotificationAgentPushover } from '../../settings';
import {
getSettings,
NotificationAgentKey,
NotificationAgentPushover,
} from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface PushoverPayload {
@@ -18,7 +28,8 @@ interface PushoverPayload {
class PushoverAgent
extends BaseAgent<NotificationAgentPushover>
implements NotificationAgent {
implements NotificationAgent
{
protected getSettings(): NotificationAgentPushover {
if (this.settings) {
return this.settings;
@@ -30,130 +41,89 @@ class PushoverAgent
}
public shouldSend(): boolean {
const settings = this.getSettings();
if (
settings.enabled &&
settings.options.accessToken &&
settings.options.userToken
) {
return true;
}
return false;
}
private constructMessageDetails(
private getNotificationPayload(
type: Notification,
payload: NotificationPayload
): {
title: string;
message: string;
url: string | undefined;
url_title: string | undefined;
priority: number;
} {
const settings = getSettings();
let messageTitle = '';
let message = '';
let url: string | undefined;
let url_title: string | undefined;
): Partial<PushoverPayload> {
const { applicationUrl, applicationTitle } = getSettings().main;
const title = payload.event ?? payload.subject;
let message = payload.event ? `<b>${payload.subject}</b>` : '';
let priority = 0;
const title = payload.subject;
const plot = payload.message;
const username = payload.request?.requestedBy.displayName;
if (payload.message) {
message += `<small>${message ? '\n' : ''}${payload.message}</small>`;
}
if (payload.request) {
message += `<small>\n\n<b>Requested By:</b> ${payload.request.requestedBy.displayName}</small>`;
let status = '';
switch (type) {
case Notification.MEDIA_PENDING:
messageTitle = `New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
message += `<b>${title}</b>`;
if (plot) {
message += `<small>\n${plot}</small>`;
}
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nPending Approval</small>`;
status = 'Pending Approval';
break;
case Notification.MEDIA_APPROVED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved`;
message += `<b>${title}</b>`;
if (plot) {
message += `<small>\n${plot}</small>`;
}
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nProcessing</small>`;
break;
case Notification.MEDIA_AUTO_APPROVED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved`;
message += `<b>${title}</b>`;
if (plot) {
message += `<small>\n${plot}</small>`;
}
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nProcessing</small>`;
status = 'Processing';
break;
case Notification.MEDIA_AVAILABLE:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available`;
message += `<b>${title}</b>`;
if (plot) {
message += `<small>\n${plot}</small>`;
}
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nAvailable</small>`;
status = 'Available';
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>`;
status = 'Declined';
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>`;
status = 'Failed';
priority = 1;
break;
case Notification.TEST_NOTIFICATION:
messageTitle = 'Test Notification';
message += `<small>${plot}</small>`;
break;
}
if (status) {
message += `<small>\n<b>Request Status:</b> ${status}</small>`;
}
} else if (payload.comment) {
message += `<small>\n\n<b>Comment from ${payload.comment.user.displayName}:</b> ${payload.comment.message}</small>`;
} else if (payload.issue) {
message += `<small>\n\n<b>Reported By:</b> ${payload.issue.createdBy.displayName}</small>`;
message += `<small>\n<b>Issue Type:</b> ${
IssueTypeName[payload.issue.issueType]
}</small>`;
message += `<small>\n<b>Issue Status:</b> ${
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
}</small>`;
if (type === Notification.ISSUE_CREATED) {
priority = 1;
}
}
for (const extra of payload.extra ?? []) {
message += `<small>\n\n<b>${extra.name}</b>\n${extra.value}</small>`;
message += `<small>\n<b>${extra.name}:</b> ${extra.value}</small>`;
}
if (settings.main.applicationUrl && payload.media) {
url = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
url_title = `Open in ${settings.main.applicationTitle}`;
}
const url = applicationUrl
? payload.issue
? `${applicationUrl}/issues/${payload.issue.id}`
: payload.media
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
: undefined;
const url_title = url
? `View ${payload.issue ? 'Issue' : 'Media'} in ${applicationTitle}`
: undefined;
return {
title: messageTitle,
title,
message,
url,
url_title,
priority,
html: 1,
};
}
@@ -162,39 +132,28 @@ class PushoverAgent
payload: NotificationPayload
): Promise<boolean> {
const settings = this.getSettings();
const endpoint = 'https://api.pushover.net/1/messages.json';
const notificationPayload = this.getNotificationPayload(type, payload);
if (!hasNotificationType(type, settings.types ?? 0)) {
return true;
}
// Send system notification
if (
hasNotificationType(type, settings.types ?? 0) &&
settings.enabled &&
settings.options.accessToken &&
settings.options.userToken
) {
logger.debug('Sending Pushover notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
});
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, {
...notificationPayload,
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',
@@ -207,6 +166,101 @@ class PushoverAgent
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;
}
}
export default PushoverAgent;

View File

@@ -1,6 +1,6 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import { MediaType } from '../../../constants/media';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import logger from '../../../logger';
import { getSettings, NotificationAgentSlack } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
@@ -19,9 +19,10 @@ interface TextItem {
interface Element {
type: 'button';
text?: TextItem;
value: string;
url: string;
action_id: 'button-action';
action_id: string;
url?: string;
value?: string;
style?: 'primary' | 'danger';
}
interface EmbedBlock {
@@ -34,10 +35,11 @@ interface EmbedBlock {
image_url: string;
alt_text: string;
};
elements?: Element[];
elements?: (Element | TextItem)[];
}
interface SlackBlockEmbed {
text: string;
blocks: EmbedBlock[];
}
@@ -59,9 +61,7 @@ class SlackAgent
type: Notification,
payload: NotificationPayload
): SlackBlockEmbed {
const settings = getSettings();
let header = '';
let actionUrl: string | undefined;
const { applicationUrl, applicationTitle } = getSettings().main;
const fields: EmbedField[] = [];
@@ -70,66 +70,55 @@ class SlackAgent
type: 'mrkdwn',
text: `*Requested By*\n${payload.request.requestedBy.displayName}`,
});
}
let status = '';
switch (type) {
case Notification.MEDIA_PENDING:
header = `New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nPending Approval',
});
status = 'Pending Approval';
break;
case Notification.MEDIA_APPROVED:
header = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nProcessing',
});
break;
case Notification.MEDIA_AUTO_APPROVED:
header = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nProcessing',
});
status = 'Processing';
break;
case Notification.MEDIA_AVAILABLE:
header = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nAvailable',
});
status = 'Available';
break;
case Notification.MEDIA_DECLINED:
header = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nDeclined',
});
status = 'Declined';
break;
case Notification.MEDIA_FAILED:
header = `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
status = 'Failed';
break;
}
if (status) {
fields.push({
type: 'mrkdwn',
text: '*Status*\nFailed',
text: `*Request Status*\n${status}`,
});
break;
case Notification.TEST_NOTIFICATION:
header = 'Test Notification';
break;
}
} else if (payload.comment) {
fields.push({
type: 'mrkdwn',
text: `*Comment from ${payload.comment.user.displayName}*\n${payload.comment.message}`,
});
} else if (payload.issue) {
fields.push(
{
type: 'mrkdwn',
text: `*Reported By*\n${payload.issue.createdBy.displayName}`,
},
{
type: 'mrkdwn',
text: `*Issue Type*\n${IssueTypeName[payload.issue.issueType]}`,
},
{
type: 'mrkdwn',
text: `*Issue Status*\n${
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
}`,
}
);
}
for (const extra of payload.extra ?? []) {
@@ -139,29 +128,27 @@ class SlackAgent
});
}
if (settings.main.applicationUrl && payload.media) {
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
const blocks: EmbedBlock[] = [];
if (payload.event) {
blocks.push({
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `*${payload.event}*`,
},
],
});
}
const blocks: EmbedBlock[] = [
{
blocks.push({
type: 'header',
text: {
type: 'plain_text',
text: header,
},
},
];
if (type !== Notification.TEST_NOTIFICATION) {
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: `*${payload.subject}*`,
text: payload.subject,
},
});
}
if (payload.message) {
blocks.push({
@@ -183,30 +170,31 @@ class SlackAgent
if (fields.length > 0) {
blocks.push({
type: 'section',
fields: [
...fields,
...(payload.extra ?? []).map(
(extra): EmbedField => ({
type: 'mrkdwn',
text: `*${extra.name}*\n${extra.value}`,
})
),
],
fields,
});
}
if (actionUrl) {
const url = applicationUrl
? payload.issue
? `${applicationUrl}/issues/${payload.issue.id}`
: payload.media
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
: undefined;
if (url) {
blocks.push({
type: 'actions',
elements: [
{
action_id: 'button-action',
action_id: 'open-in-overseerr',
type: 'button',
url: actionUrl,
value: 'open_jellyseerr',
url,
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 {
text: payload.event ?? payload.subject,
blocks,
};
}

View File

@@ -1,10 +1,13 @@
import axios from 'axios';
import { getRepository } from 'typeorm';
import { hasNotificationType, Notification } from '..';
import { MediaType } from '../../../constants/media';
import {
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import { Permission } from '../../permissions';
import {
getSettings,
NotificationAgentKey,
@@ -29,7 +32,8 @@ interface TelegramPhotoPayload {
class TelegramAgent
extends BaseAgent<NotificationAgentTelegram>
implements NotificationAgent {
implements NotificationAgent
{
private baseUrl = 'https://api.telegram.org/';
protected getSettings(): NotificationAgentTelegram {
@@ -45,11 +49,7 @@ class TelegramAgent
public shouldSend(): boolean {
const settings = this.getSettings();
if (
settings.enabled &&
settings.options.botAPI &&
settings.options.chatId
) {
if (settings.enabled && settings.options.botAPI) {
return true;
}
@@ -60,118 +60,91 @@ class TelegramAgent
return text ? text.replace(/[_*[\]()~>#+=|{}.!-]/gi, (x) => '\\' + x) : '';
}
private buildMessage(
private getNotificationPayload(
type: Notification,
payload: NotificationPayload,
chatId: string,
sendSilently: boolean
): TelegramMessagePayload | TelegramPhotoPayload {
const settings = getSettings();
let message = '';
const title = this.escapeText(payload.subject);
const plot = this.escapeText(payload.message);
const user = this.escapeText(payload.request?.requestedBy.displayName);
const applicationTitle = this.escapeText(settings.main.applicationTitle);
payload: NotificationPayload
): Partial<TelegramMessagePayload | TelegramPhotoPayload> {
const { applicationUrl, applicationTitle } = getSettings().main;
/* eslint-disable no-useless-escape */
let message = `\*${this.escapeText(
payload.event ? `${payload.event} - ${payload.subject}` : payload.subject
)}\*`;
if (payload.message) {
message += `\n${this.escapeText(payload.message)}`;
}
if (payload.request) {
message += `\n\n\*Requested By:\* ${this.escapeText(
payload.request?.requestedBy.displayName
)}`;
let status = '';
switch (type) {
case Notification.MEDIA_PENDING:
message += `\*New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nPending Approval`;
status = 'Pending Approval';
break;
case Notification.MEDIA_APPROVED:
message += `\*${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nProcessing`;
break;
case Notification.MEDIA_AUTO_APPROVED:
message += `\*${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nProcessing`;
status = 'Processing';
break;
case Notification.MEDIA_AVAILABLE:
message += `\*${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nAvailable`;
status = 'Available';
break;
case Notification.MEDIA_DECLINED:
message += `\*${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nDeclined`;
status = 'Declined';
break;
case Notification.MEDIA_FAILED:
message += `\*Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
status = 'Failed';
break;
}
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;
if (status) {
message += `\n\*Request Status:\* ${status}`;
}
} else if (payload.comment) {
message += `\n\n\*Comment from ${this.escapeText(
payload.comment.user.displayName
)}:\* ${this.escapeText(payload.comment.message)}`;
} else if (payload.issue) {
message += `\n\n\*Reported By:\* ${this.escapeText(
payload.issue.createdBy.displayName
)}`;
message += `\n\*Issue Type:\* ${IssueTypeName[payload.issue.issueType]}`;
message += `\n\*Issue Status:\* ${
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
}`;
}
for (const extra of payload.extra ?? []) {
message += `\n\n\*${extra.name}\*\n${extra.value}`;
message += `\n\*${extra.name}:\* ${extra.value}`;
}
if (settings.main.applicationUrl && payload.media) {
const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
message += `\n\n\[Open in ${applicationTitle}\]\(${actionUrl}\)`;
const url = applicationUrl
? payload.issue
? `${applicationUrl}/issues/${payload.issue.id}`
: payload.media
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
: undefined;
if (url) {
message += `\n\n\[View ${
payload.issue ? 'Issue' : 'Media'
} in ${this.escapeText(applicationTitle)}\]\(${url}\)`;
}
/* eslint-enable */
return payload.image
? ({
? {
photo: payload.image,
caption: message,
parse_mode: 'MarkdownV2',
chat_id: chatId,
disable_notification: !!sendSilently,
} as TelegramPhotoPayload)
: ({
}
: {
text: message,
parse_mode: 'MarkdownV2',
chat_id: chatId,
disable_notification: !!sendSilently,
} as TelegramMessagePayload);
};
}
public async send(
@@ -179,13 +152,16 @@ class TelegramAgent
payload: NotificationPayload
): Promise<boolean> {
const settings = this.getSettings();
const endpoint = `${this.baseUrl}bot${settings.options.botAPI}/${
payload.image ? 'sendPhoto' : 'sendMessage'
}`;
const notificationPayload = this.getNotificationPayload(type, payload);
// Send system notification
if (hasNotificationType(type, settings.types ?? 0)) {
if (
hasNotificationType(type, settings.types ?? 0) &&
settings.options.chatId
) {
logger.debug('Sending Telegram notification', {
label: 'Notifications',
type: Notification[type],
@@ -193,15 +169,11 @@ class TelegramAgent
});
try {
await axios.post(
endpoint,
this.buildMessage(
type,
payload,
settings.options.chatId,
settings.options.sendSilently
)
);
await axios.post(endpoint, {
...notificationPayload,
chat_id: settings.options.chatId,
disable_notification: !!settings.options.sendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload);
} catch (e) {
logger.error('Error sending Telegram notification', {
label: 'Notifications',
@@ -216,14 +188,13 @@ class TelegramAgent
}
if (payload.notifyUser) {
// Send notification to the user who submitted the request
if (
payload.notifyUser.settings?.hasNotificationType(
NotificationAgentKey.TELEGRAM,
type
) &&
payload.notifyUser.settings?.telegramChatId &&
payload.notifyUser.settings?.telegramChatId !== settings.options.chatId
payload.notifyUser.settings.telegramChatId !== settings.options.chatId
) {
logger.debug('Sending Telegram notification', {
label: 'Notifications',
@@ -233,15 +204,12 @@ class TelegramAgent
});
try {
await axios.post(
endpoint,
this.buildMessage(
type,
payload,
payload.notifyUser.settings.telegramChatId,
!!payload.notifyUser.settings.telegramSendSilently
)
);
await axios.post(endpoint, {
...notificationPayload,
chat_id: payload.notifyUser.settings.telegramChatId,
disable_notification:
!!payload.notifyUser.settings.telegramSendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload);
} catch (e) {
logger.error('Error sending Telegram notification', {
label: 'Notifications',
@@ -255,8 +223,9 @@ class TelegramAgent
return false;
}
}
} else {
// Send notifications to all users with the Manage Requests permission
}
if (payload.notifyAdmin) {
const userRepository = getRepository(User);
const users = await userRepository.find();
@@ -264,14 +233,10 @@ class TelegramAgent
users
.filter(
(user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
user.settings?.hasNotificationType(
NotificationAgentKey.TELEGRAM,
type
) &&
// Check if it's the user's own auto-approved request
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
) && shouldSendAdminNotification(type, user, payload)
)
.map(async (user) => {
if (
@@ -286,15 +251,11 @@ class TelegramAgent
});
try {
await axios.post(
endpoint,
this.buildMessage(
type,
payload,
user.settings.telegramChatId,
!!user.settings?.telegramSendSilently
)
);
await axios.post(endpoint, {
...notificationPayload,
chat_id: user.settings.telegramChatId,
disable_notification: !!user.settings?.telegramSendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload);
} catch (e) {
logger.error('Error sending Telegram notification', {
label: 'Notifications',

View File

@@ -1,6 +1,7 @@
import axios from 'axios';
import { get } from 'lodash';
import { hasNotificationType, Notification } from '..';
import { IssueStatus, IssueType } from '../../../constants/issue';
import { MediaStatus } from '../../../constants/media';
import logger from '../../../logger';
import { getSettings, NotificationAgentWebhook } from '../../settings';
@@ -13,6 +14,7 @@ type KeyMapFunction = (
const KeyMap: Record<string, string | KeyMapFunction> = {
notification_type: (_payload, type) => Notification[type],
event: 'event',
subject: 'subject',
message: 'message',
image: 'image',
@@ -22,13 +24,12 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
notifyuser_settings_discordId: 'notifyUser.settings.discordId',
notifyuser_settings_telegramChatId: 'notifyUser.settings.telegramChatId',
media_tmdbid: 'media.tmdbId',
media_imdbid: 'media.imdbId',
media_tvdbid: 'media.tvdbId',
media_type: 'media.mediaType',
media_status: (payload) =>
payload.media?.status ? MediaStatus[payload.media?.status] : '',
payload.media ? MediaStatus[payload.media.status] : '',
media_status4k: (payload) =>
payload.media?.status ? MediaStatus[payload.media?.status4k] : '',
payload.media ? MediaStatus[payload.media.status4k] : '',
request_id: 'request.id',
requestedBy_username: 'request.requestedBy.displayName',
requestedBy_email: 'request.requestedBy.email',
@@ -36,11 +37,28 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
requestedBy_settings_discordId: 'request.requestedBy.settings.discordId',
requestedBy_settings_telegramChatId:
'request.requestedBy.settings.telegramChatId',
issue_id: 'issue.id',
issue_type: (payload) =>
payload.issue ? IssueType[payload.issue.issueType] : '',
issue_status: (payload) =>
payload.issue ? IssueStatus[payload.issue.status] : '',
reportedBy_username: 'issue.createdBy.displayName',
reportedBy_email: 'issue.createdBy.email',
reportedBy_avatar: 'issue.createdBy.avatar',
reportedBy_settings_discordId: 'issue.createdBy.settings.discordId',
reportedBy_settings_telegramChatId: 'issue.createdBy.settings.telegramChatId',
comment_message: 'comment.message',
commentedBy_username: 'comment.user.displayName',
commentedBy_email: 'comment.user.email',
commentedBy_avatar: 'comment.user.avatar',
commentedBy_settings_discordId: 'comment.user.settings.discordId',
commentedBy_settings_telegramChatId: 'comment.user.settings.telegramChatId',
};
class WebhookAgent
extends BaseAgent<NotificationAgentWebhook>
implements NotificationAgent {
implements NotificationAgent
{
protected getSettings(): NotificationAgentWebhook {
if (this.settings) {
return this.settings;
@@ -77,6 +95,22 @@ class WebhookAgent
}
delete finalPayload[key];
key = 'request';
} else if (key === '{{issue}}') {
if (payload.issue) {
finalPayload.issue = finalPayload[key];
} else {
finalPayload.issue = null;
}
delete finalPayload[key];
key = 'issue';
} else if (key === '{{comment}}') {
if (payload.comment) {
finalPayload.comment = finalPayload[key];
} else {
finalPayload.comment = null;
}
delete finalPayload[key];
key = 'comment';
}
if (typeof finalPayload[key] === 'string') {

View File

@@ -1,11 +1,11 @@
import { getRepository } from 'typeorm';
import webpush from 'web-push';
import { Notification } from '..';
import { Notification, shouldSendAdminNotification } from '..';
import { IssueType, IssueTypeName } from '../../../constants/issue';
import { MediaType } from '../../../constants/media';
import { User } from '../../../entity/User';
import { UserPushSubscription } from '../../../entity/UserPushSubscription';
import logger from '../../../logger';
import { Permission } from '../../permissions';
import {
getSettings,
NotificationAgentConfig,
@@ -15,18 +15,18 @@ import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface PushNotificationPayload {
notificationType: string;
mediaType?: 'movie' | 'tv';
tmdbId?: number;
subject: string;
message?: string;
image?: string;
actionUrl?: string;
actionUrlTitle?: string;
requestId?: number;
}
class WebPushAgent
extends BaseAgent<NotificationAgentConfig>
implements NotificationAgent {
implements NotificationAgent
{
protected getSettings(): NotificationAgentConfig {
if (this.settings) {
return this.settings;
@@ -41,97 +41,92 @@ class WebPushAgent
type: Notification,
payload: NotificationPayload
): PushNotificationPayload {
const mediaType = payload.media
? payload.media.mediaType === MediaType.MOVIE
? 'movie'
: 'series'
: undefined;
const is4k = payload.request?.is4k;
const issueType = payload.issue
? payload.issue.issueType !== IssueType.OTHER
? `${IssueTypeName[payload.issue.issueType].toLowerCase()} issue`
: 'issue'
: undefined;
let message: string | undefined;
switch (type) {
case Notification.NONE:
case Notification.TEST_NOTIFICATION:
message = payload.message;
break;
case Notification.MEDIA_APPROVED:
message = `Your ${
is4k ? '4K ' : ''
}${mediaType} request has been approved.`;
break;
case Notification.MEDIA_AUTO_APPROVED:
message = `Automatically approved a new ${
is4k ? '4K ' : ''
}${mediaType} request from ${
payload.request?.requestedBy.displayName
}.`;
break;
case Notification.MEDIA_AVAILABLE:
message = `Your ${
is4k ? '4K ' : ''
}${mediaType} request is now available!`;
break;
case Notification.MEDIA_DECLINED:
message = `Your ${is4k ? '4K ' : ''}${mediaType} request was declined.`;
break;
case Notification.MEDIA_FAILED:
message = `Failed to process ${is4k ? '4K ' : ''}${mediaType} request.`;
break;
case Notification.MEDIA_PENDING:
message = `Approval required for a new ${
is4k ? '4K ' : ''
}${mediaType} request from ${
payload.request?.requestedBy.displayName
}.`;
break;
case Notification.ISSUE_CREATED:
message = `A new ${issueType} was reported by ${payload.issue?.createdBy.displayName}.`;
break;
case Notification.ISSUE_COMMENT:
message = `${payload.comment?.user.displayName} commented on the ${issueType}.`;
break;
case Notification.ISSUE_RESOLVED:
message = `The ${issueType} was marked as resolved by ${payload.issue?.modifiedBy?.displayName}!`;
break;
case Notification.ISSUE_REOPENED:
message = `The ${issueType} was reopened by ${payload.issue?.modifiedBy?.displayName}.`;
break;
default:
return {
notificationType: Notification[type],
subject: 'Unknown',
};
case Notification.TEST_NOTIFICATION:
return {
notificationType: Notification[type],
subject: payload.subject,
message: payload.message,
};
case Notification.MEDIA_APPROVED:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Your ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request has been approved.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
case Notification.MEDIA_AUTO_APPROVED:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Automatically approved a new ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request from ${payload.request?.requestedBy.displayName}.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
case Notification.MEDIA_AVAILABLE:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Your ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request is now available!`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
case Notification.MEDIA_DECLINED:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Your ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request was declined.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
case Notification.MEDIA_FAILED:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Failed to process ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
case Notification.MEDIA_PENDING:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Approval required for new ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request from ${payload.request?.requestedBy.displayName}.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
}
const actionUrl = payload.issue
? `/issues/${payload.issue.id}`
: payload.media
? `/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined;
const actionUrlTitle = actionUrl
? `View ${payload.issue ? 'Issue' : 'Media'}`
: undefined;
return {
notificationType: Notification[type],
subject: payload.subject,
message,
image: payload.image,
requestId: payload.request?.id,
actionUrl,
actionUrlTitle,
};
}
public shouldSend(): boolean {
@@ -150,7 +145,7 @@ class WebPushAgent
const userPushSubRepository = getRepository(UserPushSubscription);
const settings = getSettings();
let pushSubs: UserPushSubscription[] = [];
const pushSubs: UserPushSubscription[] = [];
const mainUser = await userRepository.findOne({ where: { id: 1 } });
@@ -168,13 +163,14 @@ class WebPushAgent
where: { user: payload.notifyUser.id },
});
pushSubs = notifySubs;
} else if (!payload.notifyUser) {
pushSubs.push(...notifySubs);
}
if (payload.notifyAdmin) {
const users = await userRepository.find();
const manageUsers = users.filter(
(user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
// Check if user has webpush notifications enabled and fallback to true if undefined
// since web push should default to true
(user.settings?.hasNotificationType(
@@ -182,9 +178,7 @@ class WebPushAgent
type
) ??
true) &&
// Check if it's the user's own auto-approved request
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
shouldSendAdminNotification(type, user, payload)
);
const allSubs = await userPushSubRepository
@@ -195,7 +189,7 @@ class WebPushAgent
})
.getMany();
pushSubs = allSubs;
pushSubs.push(...allSubs);
}
if (mainUser && pushSubs.length > 0) {
@@ -205,6 +199,11 @@ class WebPushAgent
settings.vapidPrivate
);
const notificationPayload = Buffer.from(
JSON.stringify(this.getNotificationPayload(type, payload)),
'utf-8'
);
await Promise.all(
pushSubs.map(async (sub) => {
logger.debug('Sending web push notification', {
@@ -223,10 +222,7 @@ class WebPushAgent
p256dh: sub.p256dh,
},
},
Buffer.from(
JSON.stringify(this.getNotificationPayload(type, payload)),
'utf-8'
)
notificationPayload
);
} catch (e) {
logger.error(

View File

@@ -1,4 +1,6 @@
import { User } from '../../entity/User';
import logger from '../../logger';
import { Permission } from '../permissions';
import type { NotificationAgent, NotificationPayload } from './agents/agent';
export enum Notification {
@@ -10,6 +12,10 @@ export enum Notification {
TEST_NOTIFICATION = 32,
MEDIA_DECLINED = 64,
MEDIA_AUTO_APPROVED = 128,
ISSUE_CREATED = 256,
ISSUE_COMMENT = 512,
ISSUE_RESOLVED = 1024,
ISSUE_REOPENED = 2048,
}
export const hasNotificationType = (
@@ -38,6 +44,50 @@ export const hasNotificationType = (
return !!(value & total);
};
export const getAdminPermission = (type: Notification): Permission => {
switch (type) {
case Notification.MEDIA_PENDING:
case Notification.MEDIA_APPROVED:
case Notification.MEDIA_AVAILABLE:
case Notification.MEDIA_FAILED:
case Notification.MEDIA_DECLINED:
case Notification.MEDIA_AUTO_APPROVED:
return Permission.MANAGE_REQUESTS;
case Notification.ISSUE_CREATED:
case Notification.ISSUE_COMMENT:
case Notification.ISSUE_RESOLVED:
case Notification.ISSUE_REOPENED:
return Permission.MANAGE_ISSUES;
default:
return Permission.ADMIN;
}
};
export const shouldSendAdminNotification = (
type: Notification,
user: User,
payload: NotificationPayload
): boolean => {
return (
user.id !== payload.notifyUser?.id &&
user.hasPermission(getAdminPermission(type)) &&
// Check if the user submitted this request (on behalf of themself OR another user)
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !==
(payload.request?.modifiedBy ?? payload.request?.requestedBy)?.id) &&
// Check if the user created this issue
(type !== Notification.ISSUE_CREATED ||
user.id !== payload.issue?.createdBy.id) &&
// Check if the user submitted this issue comment
(type !== Notification.ISSUE_COMMENT ||
user.id !== payload.comment?.user.id) &&
// Check if the user resolved/reopened this issue
((type !== Notification.ISSUE_RESOLVED &&
type !== Notification.ISSUE_REOPENED) ||
user.id !== payload.issue?.modifiedBy?.id)
);
};
class NotificationManager {
private activeAgents: NotificationAgent[] = [];

View File

@@ -19,6 +19,9 @@ export enum Permission {
AUTO_APPROVE_4K_TV = 131072,
REQUEST_MOVIE = 262144,
REQUEST_TV = 524288,
MANAGE_ISSUES = 1048576,
VIEW_ISSUES = 2097152,
CREATE_ISSUES = 4194304,
}
export interface PermissionCheckOptions {

View File

@@ -146,9 +146,8 @@ class BaseScanner<T> {
existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] !==
externalServiceId
) {
existing[
is4k ? 'externalServiceId4k' : 'externalServiceId'
] = externalServiceId;
existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
externalServiceId;
changedExisting = true;
}
@@ -157,9 +156,8 @@ class BaseScanner<T> {
existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
externalServiceSlug
) {
existing[
is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
] = externalServiceSlug;
existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
externalServiceSlug;
changedExisting = true;
}
@@ -390,15 +388,13 @@ class BaseScanner<T> {
}
if (externalServiceId !== undefined) {
media[
is4k ? 'externalServiceId4k' : 'externalServiceId'
] = externalServiceId;
media[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
externalServiceId;
}
if (externalServiceSlug !== undefined) {
media[
is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
] = externalServiceSlug;
media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
externalServiceSlug;
}
// 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
extends BaseScanner<PlexLibraryItem>
implements RunnableScanner<SyncStatus> {
implements RunnableScanner<SyncStatus>
{
private plexClient: PlexAPI;
private libraries: 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 (mediaIds.imdbId && !mediaIds.tmdbId) {
const tmdbMovie = await this.tmdb.getMovieByImdbId({
const tmdbMedia = await this.tmdb.getMediaByImdbId({
imdbId: mediaIds.imdbId,
});
mediaIds.tmdbId = tmdbMovie.id;
mediaIds.tmdbId = tmdbMedia.id;
}
// Cache GUIDs
@@ -384,10 +385,10 @@ class PlexScanner
const imdbMatch = plexitem.guid.match(imdbRegex);
if (imdbMatch) {
mediaIds.imdbId = imdbMatch[1];
const tmdbMovie = await this.tmdb.getMovieByImdbId({
const tmdbMedia = await this.tmdb.getMediaByImdbId({
imdbId: mediaIds.imdbId,
});
mediaIds.tmdbId = tmdbMovie.id;
mediaIds.tmdbId = tmdbMedia.id;
}
// Check if the agent is TMDb
} else if (plexitem.guid.match(tmdbRegex)) {
@@ -472,7 +473,7 @@ class PlexScanner
mediaIds.tmdbId = result.tmdbId;
mediaIds.imdbId = result?.imdbId;
} else if (result?.imdbId) {
const tmdbMovie = await this.tmdb.getMovieByImdbId({
const tmdbMovie = await this.tmdb.getMediaByImdbId({
imdbId: result.imdbId,
});
mediaIds.tmdbId = tmdbMovie.id;
@@ -521,7 +522,7 @@ class PlexScanner
if (special.tmdbId) {
await this.processPlexMovieByTmdbId(episode, special.tmdbId);
} else if (special.imdbId) {
const tmdbMovie = await this.tmdb.getMovieByImdbId({
const tmdbMovie = await this.tmdb.getMediaByImdbId({
imdbId: special.imdbId,
});
await this.processPlexMovieByTmdbId(episode, tmdbMovie.id);

View File

@@ -10,7 +10,8 @@ type SyncStatus = StatusBase & {
class RadarrScanner
extends BaseScanner<RadarrMovie>
implements RunnableScanner<SyncStatus> {
implements RunnableScanner<SyncStatus>
{
private servers: RadarrSettings[];
private currentServer: RadarrSettings;
private radarrApi: RadarrAPI;
@@ -72,7 +73,7 @@ class RadarrScanner
}
private async processRadarrMovie(radarrMovie: RadarrMovie): Promise<void> {
if (!radarrMovie.monitored && !radarrMovie.downloaded) {
if (!radarrMovie.monitored && !radarrMovie.hasFile) {
this.log(
'Title is unmonitored and has not been downloaded. Skipping item.',
'debug',
@@ -91,7 +92,7 @@ class RadarrScanner
externalServiceId: radarrMovie.id,
externalServiceSlug: radarrMovie.titleSlug,
title: radarrMovie.title,
processing: !radarrMovie.downloaded,
processing: !radarrMovie.hasFile,
});
} catch (e) {
this.log('Failed to process Radarr media', 'error', {

View File

@@ -1,6 +1,7 @@
import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
import SonarrAPI, { SonarrSeries } from '../../../api/servarr/sonarr';
import { TmdbTvDetails } from '../../../api/themoviedb/interfaces';
import Media from '../../../entity/Media';
import { getSettings, SonarrSettings } from '../../settings';
import BaseScanner, {
@@ -16,7 +17,8 @@ type SyncStatus = StatusBase & {
class SonarrScanner
extends BaseScanner<SonarrSeries>
implements RunnableScanner<SyncStatus> {
implements RunnableScanner<SyncStatus>
{
private servers: SonarrSettings[];
private currentServer: SonarrSettings;
private sonarrApi: SonarrAPI;
@@ -82,24 +84,26 @@ class SonarrScanner
const mediaRepository = getRepository(Media);
const server4k = this.enable4kShow && this.currentServer.is4k;
const processableSeasons: ProcessableSeason[] = [];
let tmdbId: number;
let tvShow: TmdbTvDetails;
const media = await mediaRepository.findOne({
where: { tvdbId: sonarrSeries.tvdbId },
});
if (!media || !media.tmdbId) {
const tvShow = await this.tmdb.getShowByTvdbId({
tvShow = await this.tmdb.getShowByTvdbId({
tvdbId: sonarrSeries.tvdbId,
});
tmdbId = tvShow.id;
} else {
tmdbId = media.tmdbId;
tvShow = await this.tmdb.getTvShow({ tvId: media.tmdbId });
}
const tmdbId = tvShow.id;
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) {

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 {
name: string;
hostname?: string;
externalHostname?: string;
libraries: Library[];
serverId: string;
}
export interface TautulliSettings {
hostname?: string;
port?: number;
useSsl?: boolean;
urlBase?: string;
apiKey?: string;
externalUrl?: string;
}
export interface DVRSettings {
id: number;
@@ -125,6 +134,7 @@ interface FullPublicSettings extends PublicSettings {
enablePushRegistration: boolean;
locale: string;
emailEnabled: boolean;
newPlexLogin: boolean;
}
export interface NotificationAgentConfig {
@@ -137,6 +147,7 @@ export interface NotificationAgentDiscord extends NotificationAgentConfig {
botUsername?: string;
botAvatarUrl?: string;
webhookUrl: string;
enableMentions: boolean;
};
}
@@ -182,6 +193,7 @@ export interface NotificationAgentTelegram extends NotificationAgentConfig {
export interface NotificationAgentPushbullet extends NotificationAgentConfig {
options: {
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 {
DISCORD = 'discord',
EMAIL = 'email',
GOTIFY = 'gotify',
PUSHBULLET = 'pushbullet',
PUSHOVER = 'pushover',
SLACK = 'slack',
@@ -214,6 +234,7 @@ export enum NotificationAgentKey {
interface NotificationAgents {
discord: NotificationAgentDiscord;
email: NotificationAgentEmail;
gotify: NotificationAgentGotify;
lunasea: NotificationAgentLunaSea;
pushbullet: NotificationAgentPushbullet;
pushover: NotificationAgentPushover;
@@ -227,6 +248,20 @@ interface NotificationSettings {
agents: NotificationAgents;
}
interface JobSettings {
schedule: string;
}
export type JobId =
| 'plex-recently-added-scan'
| 'plex-full-scan'
| 'radarr-scan'
| 'sonarr-scan'
| 'download-sync'
| 'download-sync-reset'
| 'jellyfin-recently-added-sync'
| 'jellyfin-full-sync';
interface AllSettings {
clientId: string;
vapidPublic: string;
@@ -234,10 +269,12 @@ interface AllSettings {
main: MainSettings;
plex: PlexSettings;
jellyfin: JellyfinSettings;
tautulli: TautulliSettings;
radarr: RadarrSettings[];
sonarr: SonarrSettings[];
public: PublicSettings;
notifications: NotificationSettings;
jobs: Record<JobId, JobSettings>;
}
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
@@ -283,9 +320,11 @@ class Settings {
jellyfin: {
name: '',
hostname: '',
externalHostname: '',
libraries: [],
serverId: '',
},
tautulli: {},
radarr: [],
sonarr: [],
public: {
@@ -303,7 +342,7 @@ class Settings {
ignoreTls: false,
requireTls: false,
allowSelfSigned: false,
senderName: 'Jellyseerr',
senderName: 'Overseerr',
},
},
discord: {
@@ -311,6 +350,7 @@ class Settings {
types: 0,
options: {
webhookUrl: '',
enableMentions: true,
},
},
lunasea: {
@@ -357,13 +397,47 @@ class Settings {
options: {
webhookUrl: '',
jsonPayload:
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJzdWJqZWN0XCI6IFwie3tzdWJqZWN0fX1cIixcbiAgICBcIm1lc3NhZ2VcIjogXCJ7e21lc3NhZ2V9fVwiLFxuICAgIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgICBcImVtYWlsXCI6IFwie3tub3RpZnl1c2VyX2VtYWlsfX1cIixcbiAgICBcInVzZXJuYW1lXCI6IFwie3tub3RpZnl1c2VyX3VzZXJuYW1lfX1cIixcbiAgICBcImF2YXRhclwiOiBcInt7bm90aWZ5dXNlcl9hdmF0YXJ9fVwiLFxuICAgIFwie3ttZWRpYX19XCI6IHtcbiAgICAgICAgXCJtZWRpYV90eXBlXCI6IFwie3ttZWRpYV90eXBlfX1cIixcbiAgICAgICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgICAgIFwiaW1kYklkXCI6IFwie3ttZWRpYV9pbWRiaWR9fVwiLFxuICAgICAgICBcInR2ZGJJZFwiOiBcInt7bWVkaWFfdHZkYmlkfX1cIixcbiAgICAgICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgICAgIFwic3RhdHVzNGtcIjogXCJ7e21lZGlhX3N0YXR1czRrfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW10sXG4gICAgXCJ7e3JlcXVlc3R9fVwiOiB7XG4gICAgICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfZW1haWxcIjogXCJ7e3JlcXVlc3RlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV91c2VybmFtZVwiOiBcInt7cmVxdWVzdGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIlxuICAgIH1cbn0i',
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
},
},
webpush: {
enabled: false,
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;
}
get tautulli(): TautulliSettings {
return this.data.tautulli;
}
set tautulli(data: TautulliSettings) {
this.data.tautulli = data;
}
get radarr(): RadarrSettings[] {
return this.data.radarr;
}
@@ -447,6 +529,7 @@ class Settings {
enablePushRegistration: this.data.notifications.agents.webpush.enabled,
locale: this.data.main.locale,
emailEnabled: this.data.notifications.agents.email.enabled,
newPlexLogin: this.data.main.newPlexLogin,
};
}
@@ -458,6 +541,14 @@ class Settings {
this.data.notifications = data;
}
get jobs(): Record<JobId, JobSettings> {
return this.data.jobs;
}
set jobs(data: Record<JobId, JobSettings>) {
this.data.jobs = data;
}
get clientId(): string {
if (!this.data.clientId) {
this.data.clientId = randomUUID();

View File

@@ -4,7 +4,7 @@ import * as winston from 'winston';
import 'winston-daily-rotate-file';
// 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)) {
const file = fs.lstatSync(OLD_LOG_FILE);
@@ -43,14 +43,30 @@ const logger = winston.createLogger({
}),
new winston.transports.DailyRotateFile({
filename: process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/logs/Jellyseerr-%DATE%.log`
: path.join(__dirname, '../config/logs/Jellyseerr-%DATE%.log'),
? `${process.env.CONFIG_DIRECTORY}/logs/overseerr-%DATE%.log`
: path.join(__dirname, '../config/logs/overseerr-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '7d',
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';
export class AddUserRequestDeleteCascades1608219049304
implements MigrationInterface {
implements MigrationInterface
{
name = 'AddUserRequestDeleteCascades1608219049304';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,7 +1,8 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddLastSeasonChangeMedia1608477467935
implements MigrationInterface {
implements MigrationInterface
{
name = 'AddLastSeasonChangeMedia1608477467935';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,7 +1,8 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ForceDropImdbUniqueConstraint1608477467935
implements MigrationInterface {
implements MigrationInterface
{
name = 'ForceDropImdbUniqueConstraint1608477467936';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,7 +1,8 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RemoveTmdbIdUniqueConstraint1609236552057
implements MigrationInterface {
implements MigrationInterface
{
name = 'RemoveTmdbIdUniqueConstraint1609236552057';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,7 +1,8 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMediaAddedFieldToMedia1610522845513
implements MigrationInterface {
implements MigrationInterface
{
name = 'AddMediaAddedFieldToMedia1610522845513';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,7 +1,8 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class SonarrRadarrSyncServiceFields1611757511674
implements MigrationInterface {
implements MigrationInterface
{
name = 'SonarrRadarrSyncServiceFields1611757511674';
public async up(queryRunner: QueryRunner): Promise<void> {

Some files were not shown because too many files have changed in this diff Show More