diff --git a/.all-contributorsrc b/.all-contributorsrc index c5cc5d971..a230a4685 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -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": "\"All-orange.svg\"/>", "contributorsPerLine": 7, - "projectName": "jellyseerr", - "projectOwner": "Fallenbagel", + "projectName": "overseerr", + "projectOwner": "sct", "repoType": "github", "repoHost": "https://github.com", "skipCi": true diff --git a/.dockerignore b/.dockerignore index 3ddaa574e..7d669c86d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,6 +10,7 @@ .gitconfig .github .gitignore +.husky .next .prettierignore config/db/* diff --git a/.eslintrc.js b/.eslintrc.js index 3b6ea1866..b1c6f4b9f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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', diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 000000000..9bfffb24b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 4b1d37900..000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -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. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index f65cfa766..13a684958 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/enhancement.yml b/.github/ISSUE_TEMPLATE/enhancement.yml new file mode 100644 index 000000000..aee473591 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/enhancement.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 29b26fbd7..000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -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. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 3de961902..d8187ecf0 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -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 diff --git a/.github/stale.yml b/.github/stale.yml index eeed081e3..a0c96e871 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -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. diff --git a/.gitignore b/.gitignore index 0bc4be4a5..7d606105b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.husky/commit-msg b/.husky/commit-msg index 958988670..61e13c0e0 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -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 diff --git a/.husky/pre-commit b/.husky/pre-commit index 449fcdee1..36af21989 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -npm test +npx lint-staged diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg index 73a4cdc33..17e2764cd 100755 --- a/.husky/prepare-commit-msg +++ b/.husky/prepare-commit-msg @@ -1,4 +1,4 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -exec < /dev/tty && git cz --hook || true +exec < /dev/tty && npx cz --hook || true diff --git a/.vscode/extensions.json b/.vscode/extensions.json index fb896f97e..80a16c644 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -19,9 +19,6 @@ "stylelint.vscode-stylelint", - "bradlc.vscode-tailwindcss", - - // https://marketplace.visualstudio.com/items?itemName=heybourn.headwind - "heybourn.headwind" + "bradlc.vscode-tailwindcss" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 799ff92ae..26aca34b8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,7 +15,6 @@ "database": "./config/db/db.sqlite3" } ], - "i18n-ally.localesPaths": ["src/i18n", "src/i18n/locale"], "editor.codeActionsOnSave": { "source.organizeImports": true }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 755910ff3..d7e9d1cb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,1197 @@ -## [1.0.2](https://github.com/Fallenbagel/jellyseerr/compare/v1.0.1...v1.0.2) (2022-04-20) - +## [1.29.1](https://github.com/sct/overseerr/compare/v1.29.0...v1.29.1) (2022-04-06) ### Bug Fixes -* fix usertype from local user to mediaServerType ([6d530d9](https://github.com/Fallenbagel/jellyseerr/commit/6d530d90280c929c7e8b185eb379e98c2d0a8ef9)) -* relax jellyfin url validation to allow local domains ([9c43ba9](https://github.com/Fallenbagel/jellyseerr/commit/9c43ba95e6c368b87acbfa8bac16c385605e7502)), closes [#123](https://github.com/Fallenbagel/jellyseerr/issues/123) +- **auth:** resolve local/password authentication issues ([#2677](https://github.com/sct/overseerr/issues/2677)) ([b75fc7b](https://github.com/sct/overseerr/commit/b75fc7b2384ce760432620faaa92277dcd42b8e1)) + +# [1.29.0](https://github.com/sct/overseerr/compare/v1.28.0...v1.29.0) (2022-04-01) + +### Bug Fixes + +- add Discord ID setting to general user settings page ([#2406](https://github.com/sct/overseerr/issues/2406)) ([eff665e](https://github.com/sct/overseerr/commit/eff665ef4b688aac881408790304b77bd9a31ddb)) +- address unhandled promise rejections & bump node to v16.13 ([#2398](https://github.com/sct/overseerr/issues/2398)) ([8cba486](https://github.com/sct/overseerr/commit/8cba486249fed88232e93a688c8bfe0f6179c589)) +- **css:** rename form-input to form-input-area ([#2613](https://github.com/sct/overseerr/issues/2613)) ([086f0b6](https://github.com/sct/overseerr/commit/086f0b6ce23f607d20c2cec3c73b2e4d1ce9b426)) +- **email:** enclose PGP encryption logic in try/catch ([#2519](https://github.com/sct/overseerr/issues/2519)) ([a76b608](https://github.com/sct/overseerr/commit/a76b608ab796944c0c660e3296a7aca6615d69f3)) +- **frontend:** disable autocomplete on search field ([#2592](https://github.com/sct/overseerr/issues/2592)) ([82d1617](https://github.com/sct/overseerr/commit/82d16177bf763fe8097b4aae326793e3e21e847d)) +- **frontend:** theme-color meta tag ([#2420](https://github.com/sct/overseerr/issues/2420)) ([ff28c9b](https://github.com/sct/overseerr/commit/ff28c9bfebf4a930e2542ee3b3c35f8af4e1b97e)) +- **frontend:** various fixes ([#2524](https://github.com/sct/overseerr/issues/2524)) ([c3dbd0d](https://github.com/sct/overseerr/commit/c3dbd0d6913946e0e1b5308edfbb5ca744740223)) +- **lang:** rename 'Media' notification types for clarity ([#2400](https://github.com/sct/overseerr/issues/2400)) ([399b037](https://github.com/sct/overseerr/commit/399b0379186ed34dcc436bd95330fd1a05fef4b3)) +- **lang:** translations update from Hosted Weblate ([#2625](https://github.com/sct/overseerr/issues/2625)) ([19cdedd](https://github.com/sct/overseerr/commit/19cdedd2a6656b1a852e1cc653bbdb140e978b51)) +- **lang:** translations update from Hosted Weblate ([#2639](https://github.com/sct/overseerr/issues/2639)) ([418a533](https://github.com/sct/overseerr/commit/418a533588bbbdbbbb4caee1ef91d57c1ca35717)) +- **logs:** handle log message nested extra properties ([#2459](https://github.com/sct/overseerr/issues/2459)) ([d777940](https://github.com/sct/overseerr/commit/d7779408d162949b2eafcacefc8eabe53fae229f)) +- **notif:** duplicate notification check logic ([#2424](https://github.com/sct/overseerr/issues/2424)) ([10651ba](https://github.com/sct/overseerr/commit/10651baa675993f7109989bbac67f54661c8693f)) +- **notif:** show event in pop up notification for slack ([#2413](https://github.com/sct/overseerr/issues/2413)) ([d4438c8](https://github.com/sct/overseerr/commit/d4438c82e3753c9b29b6269ad406d263b3fcef4c)), closes [#2408](https://github.com/sct/overseerr/issues/2408) +- **plex:** correctly generate uuid for safari ([#2614](https://github.com/sct/overseerr/issues/2614)) ([d06f2cd](https://github.com/sct/overseerr/commit/d06f2cdb08bfa6f05cf7cec2c408a258fa926b09)) +- **plex:** find TV series in addition to movies from IMDb IDs ([#1830](https://github.com/sct/overseerr/issues/1830)) ([30644f6](https://github.com/sct/overseerr/commit/30644f65ea2e8437676422ae0b083c642a836887)) +- **plex:** include 'Overseerr' in X-Plex-Device-Name header ([#2635](https://github.com/sct/overseerr/issues/2635)) ([d4f9650](https://github.com/sct/overseerr/commit/d4f9650cd07704a97f8b591b7de7351c1e85b825)) +- **plex:** use unique client identifier ([#2602](https://github.com/sct/overseerr/issues/2602)) ([648b346](https://github.com/sct/overseerr/commit/648b346cbe5a941c7e1ec4ddfb276fb0e27ed502)) +- **plex:** user import ([#2442](https://github.com/sct/overseerr/issues/2442)) ([86dff12](https://github.com/sct/overseerr/commit/86dff12cdeef6dca92527dd31757a3a4c7f921bf)) +- **radarr:** correctly check for existing movies ([#2490](https://github.com/sct/overseerr/issues/2490)) ([5d4b06b](https://github.com/sct/overseerr/commit/5d4b06bbcc6cf6d328f6b4a86c4c0f9b0f3aff3e)) +- **radarr:** remove PreDB minimum availability option ([#2386](https://github.com/sct/overseerr/issues/2386)) ([3e5eb4e](https://github.com/sct/overseerr/commit/3e5eb4e148a9f88b871abc4ee1784b870f691534)) +- **requests:** check for existing media of same type when requesting ([#2445](https://github.com/sct/overseerr/issues/2445)) ([eb9ca2e](https://github.com/sct/overseerr/commit/eb9ca2e86f3be3f4ff8ee2e7c4aecdf337d8976d)) +- **sonarr:** monitor existing series upon request approval ([#2553](https://github.com/sct/overseerr/issues/2553)) ([aa062d9](https://github.com/sct/overseerr/commit/aa062d921c425d4b64bfdb28a5f102b0c92f7d87)) +- **sonarr:** only scan seasons that exist in TMDb ([#2523](https://github.com/sct/overseerr/issues/2523)) ([6168185](https://github.com/sct/overseerr/commit/61681857b123802aaeff02a8f61b1ba046c5d333)) +- **tautulli:** fetch additional user history as necessary to return 20 unique media ([#2446](https://github.com/sct/overseerr/issues/2446)) ([7d19de6](https://github.com/sct/overseerr/commit/7d19de6a4af6297be18140ca59402b40f7bbb30b)) + +### Features + +- **about:** show config directory ([#2600](https://github.com/sct/overseerr/issues/2600)) ([0c7373c](https://github.com/sct/overseerr/commit/0c7373c7e89a4ff717efaa7d6a5854f7ccd6a8d3)) +- **api:** add additional request counts ([#2426](https://github.com/sct/overseerr/issues/2426)) ([2535edc](https://github.com/sct/overseerr/commit/2535edcc7fd6ec66fd45ad754c03929f1fe94871)) +- **discord:** add 'Enable Mentions' setting ([#1779](https://github.com/sct/overseerr/issues/1779)) ([5f7538a](https://github.com/sct/overseerr/commit/5f7538ae2bf9c6e2feea385cc299bd08df071218)) +- **frontend:** open media management slideover on status badge click ([#2407](https://github.com/sct/overseerr/issues/2407)) ([1f5785d](https://github.com/sct/overseerr/commit/1f5785d6c53b2ca2da67a8ccee72165c052c61a1)) +- **lang:** add Albanian display language ([#2605](https://github.com/sct/overseerr/issues/2605)) ([3d32462](https://github.com/sct/overseerr/commit/3d32462f50b4ced0d9205b79003c35d6d1c948a3)) +- **lang:** translations update from Hosted Weblate ([#2379](https://github.com/sct/overseerr/issues/2379)) ([bd93168](https://github.com/sct/overseerr/commit/bd93168ba1ed650baf4024569bb6a76811a99820)) +- **lang:** translations update from Hosted Weblate ([#2389](https://github.com/sct/overseerr/issues/2389)) ([d2241a4](https://github.com/sct/overseerr/commit/d2241a41877d126a802fc53c925d258af31f34fd)) +- **lang:** translations update from Hosted Weblate ([#2404](https://github.com/sct/overseerr/issues/2404)) ([1b29b15](https://github.com/sct/overseerr/commit/1b29b15d7c9a7ec918cb59116d60e1ae2e797dc4)) +- **lang:** translations update from Hosted Weblate ([#2405](https://github.com/sct/overseerr/issues/2405)) ([879df20](https://github.com/sct/overseerr/commit/879df20022c8c5d9b32858ac5499d3e4369fc064)) +- **lang:** translations update from Hosted Weblate ([#2414](https://github.com/sct/overseerr/issues/2414)) ([88536b1](https://github.com/sct/overseerr/commit/88536b1f9d6e8c1a11e1adf91b85bab4f34b751c)) +- **lang:** translations update from Hosted Weblate ([#2425](https://github.com/sct/overseerr/issues/2425)) ([e9d4b63](https://github.com/sct/overseerr/commit/e9d4b6327b50a005ee6c2c3292b6f107e90fc50c)) +- **lang:** translations update from Hosted Weblate ([#2428](https://github.com/sct/overseerr/issues/2428)) ([f8b1bcc](https://github.com/sct/overseerr/commit/f8b1bccda44371bb6f3f8f4ceeab900b1df3de31)) +- **lang:** translations update from Hosted Weblate ([#2436](https://github.com/sct/overseerr/issues/2436)) ([99c0407](https://github.com/sct/overseerr/commit/99c04072e9f7be8191f25cbcfd5103017b8796eb)) +- **lang:** translations update from Hosted Weblate ([#2452](https://github.com/sct/overseerr/issues/2452)) ([b5bd6ee](https://github.com/sct/overseerr/commit/b5bd6ee78f3d4aa14f0c440d1f2a8323dccfa399)) +- **lang:** translations update from Hosted Weblate ([#2457](https://github.com/sct/overseerr/issues/2457)) ([92b2d32](https://github.com/sct/overseerr/commit/92b2d32d2e1e1d319410a9e357e1304065a77598)) +- **lang:** translations update from Hosted Weblate ([#2489](https://github.com/sct/overseerr/issues/2489)) ([ec08fa6](https://github.com/sct/overseerr/commit/ec08fa67934715ff4a4d618d5b9ff97853913b78)) +- **lang:** translations update from Hosted Weblate ([#2508](https://github.com/sct/overseerr/issues/2508)) ([9f4ae34](https://github.com/sct/overseerr/commit/9f4ae34da76707a40e2c89a50c722ffa1c0327c0)) +- **lang:** translations update from Hosted Weblate ([#2531](https://github.com/sct/overseerr/issues/2531)) ([54b32eb](https://github.com/sct/overseerr/commit/54b32ebfd6b2eb6aeeea98c25939166eda8cc17f)) +- **lang:** translations update from Hosted Weblate ([#2541](https://github.com/sct/overseerr/issues/2541)) ([4549ed3](https://github.com/sct/overseerr/commit/4549ed389e4f25c0946dc01526387e5ac000c3cf)) +- **lang:** translations update from Hosted Weblate ([#2611](https://github.com/sct/overseerr/issues/2611)) ([81c75c8](https://github.com/sct/overseerr/commit/81c75c800edf6d36a1082a291ef7e308f338d005)) +- **lang:** translations update from Hosted Weblate ([#2629](https://github.com/sct/overseerr/issues/2629)) ([1d0cbd2](https://github.com/sct/overseerr/commit/1d0cbd2e761072be0b4b3de461397ad9f9f681f3)) +- **lang:** translations update from Hosted Weblate ([#2645](https://github.com/sct/overseerr/issues/2645)) ([341e3b8](https://github.com/sct/overseerr/commit/341e3b8f0657e09f53ad0b813b051290947343c0)) +- **logs:** use separate json file to parse logs for log viewer ([#2399](https://github.com/sct/overseerr/issues/2399)) ([ce31bef](https://github.com/sct/overseerr/commit/ce31bef8a125c5492f2a1cfef0dcf3d8a4e9ee11)) +- **notif:** add Gotify agent ([#2196](https://github.com/sct/overseerr/issues/2196)) ([e0b6abe](https://github.com/sct/overseerr/commit/e0b6abe4796f5a324c0ff78cff317fcaead671f1)), closes [#2183](https://github.com/sct/overseerr/issues/2183) [#2183](https://github.com/sct/overseerr/issues/2183) [#2077](https://github.com/sct/overseerr/issues/2077) [#2183](https://github.com/sct/overseerr/issues/2183) [#2183](https://github.com/sct/overseerr/issues/2183) [#2183](https://github.com/sct/overseerr/issues/2183) [#2077](https://github.com/sct/overseerr/issues/2077) [#2183](https://github.com/sct/overseerr/issues/2183) [#2183](https://github.com/sct/overseerr/issues/2183) [#2183](https://github.com/sct/overseerr/issues/2183) +- **notif:** add Pushbullet channel tag ([#2198](https://github.com/sct/overseerr/issues/2198)) ([f9200b7](https://github.com/sct/overseerr/commit/f9200b7977208f9b8267ce3a74bd8a86d6f28f7b)) +- **plex:** selective user import ([#2188](https://github.com/sct/overseerr/issues/2188)) ([9cb97db](https://github.com/sct/overseerr/commit/9cb97db13ced5df2dc595cd9033470b1a0750093)) +- **search:** filter search results by year ([#2460](https://github.com/sct/overseerr/issues/2460)) ([72c825d](https://github.com/sct/overseerr/commit/72c825d2a5109688bcc1991a30249284bf281500)) +- **search:** search by id ([#2082](https://github.com/sct/overseerr/issues/2082)) ([b31cdbf](https://github.com/sct/overseerr/commit/b31cdbf074d5dbecbbf6da135a9b686aea9e3c0e)) +- Tautulli integration ([#2230](https://github.com/sct/overseerr/issues/2230)) ([0842c23](https://github.com/sct/overseerr/commit/0842c233d0fc56d44824cad18749492cd52cbed5)) +- **tautulli:** validate upon saving settings ([#2511](https://github.com/sct/overseerr/issues/2511)) ([1dc900d](https://github.com/sct/overseerr/commit/1dc900d5ce9689d179c9d2f554abc74ca50bd9cb)) +- **ui:** add trakt external link ([#2367](https://github.com/sct/overseerr/issues/2367)) ([4e56bae](https://github.com/sct/overseerr/commit/4e56bae98508c1a60aeb3a08560ba1c00acce7e7)) +- verify Plex server access during auth for existing users with Plex IDs ([#2458](https://github.com/sct/overseerr/issues/2458)) ([85bb30e](https://github.com/sct/overseerr/commit/85bb30e252c27047ae367491f0e5bb92a7d52605)) + +# [1.28.0](https://github.com/sct/overseerr/compare/v1.27.0...v1.28.0) (2022-01-01) + +### Bug Fixes + +- add missing route guards to issues pages ([#2235](https://github.com/sct/overseerr/issues/2235)) ([c79dc9f](https://github.com/sct/overseerr/commit/c79dc9f70f512dbec0e3460ee78dbc9feccfbbb1)) +- allow basic HTTP auth in hostname validation ([#2307](https://github.com/sct/overseerr/issues/2307)) ([d48a7ba](https://github.com/sct/overseerr/commit/d48a7ba518f9c79d70e499037cb730eb3efe2c08)) +- **docker:** explicitly install python3 ([#2273](https://github.com/sct/overseerr/issues/2273)) [skip ci] ([f1cd087](https://github.com/sct/overseerr/commit/f1cd0878a5c74bddc864f5f8ce9e2f041bdde5ec)) +- **email:** use decrypted private key ([#2232](https://github.com/sct/overseerr/issues/2232)) ([8d29685](https://github.com/sct/overseerr/commit/8d2968572a569ed77a4d7c14ae1dc69935fa847e)) +- **frontend:** more issues-related fixes ([#2234](https://github.com/sct/overseerr/issues/2234)) ([3ec4a9c](https://github.com/sct/overseerr/commit/3ec4a9c76e1f31bee5c8801b389721bf8e5884e0)) +- **frontend:** setup page backdrops ([#2251](https://github.com/sct/overseerr/issues/2251)) ([78a8091](https://github.com/sct/overseerr/commit/78a8091bcd29a7cf50cc7c493c28710389817adf)) +- **frontend:** use consistent formatting & strings ([#2231](https://github.com/sct/overseerr/issues/2231)) ([2164471](https://github.com/sct/overseerr/commit/216447121b686b6d01a31b95ec0c8eb005f6b103)) +- handle Plex library settings migration failure gracefully ([#2254](https://github.com/sct/overseerr/issues/2254)) ([ed53810](https://github.com/sct/overseerr/commit/ed53810fb33f70722361c67d176ff4edf531ba45)) +- **issues:** only allow edit of own comments & do not allow non-admin delete of issues with comments ([#2248](https://github.com/sct/overseerr/issues/2248)) ([bba09d6](https://github.com/sct/overseerr/commit/bba09d69c1bc55c2f35db5a7986e7c935cc9619c)) +- **lang:** add missing string ([#2370](https://github.com/sct/overseerr/issues/2370)) ([d36c1d2](https://github.com/sct/overseerr/commit/d36c1d29295020efb76bac21a443b6f9049802f3)) +- **lang:** string edits ([#2229](https://github.com/sct/overseerr/issues/2229)) ([ab20c21](https://github.com/sct/overseerr/commit/ab20c21184639e1c7725f7cae96249c6fa157351)) +- **lang:** translations update from Weblate ([#2212](https://github.com/sct/overseerr/issues/2212)) ([85aec4f](https://github.com/sct/overseerr/commit/85aec4f8925746ebae9bcc99d8480b78ccfd851e)) +- **logs:** handle unexpected log messages ([#2303](https://github.com/sct/overseerr/issues/2303)) ([f284e4a](https://github.com/sct/overseerr/commit/f284e4ab978e502d2cc08e76226a8ebac91bb48f)) +- **logs:** lazily parse log message label ([#2359](https://github.com/sct/overseerr/issues/2359)) ([5af06bd](https://github.com/sct/overseerr/commit/5af06bd87226fbc6176b0c5e362824793165a34e)) +- **notif:** correct issue notif action URLs ([#2333](https://github.com/sct/overseerr/issues/2333)) ([dc7f959](https://github.com/sct/overseerr/commit/dc7f959cb422a8d89bcebc78377f1513412e542c)) +- **notif:** only send MEDIA_AVAILABLE notifications for non-declined requests ([#2343](https://github.com/sct/overseerr/issues/2343)) ([fcb0dcf](https://github.com/sct/overseerr/commit/fcb0dcf5be64bf9ca814bfe119586908922099c5)) +- **requests:** do not fail request edits if acting user lacks Manage Users permission ([#2338](https://github.com/sct/overseerr/issues/2338)) ([91bfff7](https://github.com/sct/overseerr/commit/91bfff71b7c05c9b9aad2c95282533eefbb6b2e7)) +- secure session cookie ([#2308](https://github.com/sct/overseerr/issues/2308)) ([7f330af](https://github.com/sct/overseerr/commit/7f330aff2e1d3546e8dd1a3e4b037b9beb1cc7f0)) +- **servarr:** handle baseurl error when testing connection ([#2294](https://github.com/sct/overseerr/issues/2294)) ([93b5ea2](https://github.com/sct/overseerr/commit/93b5ea20ca590996f6dc90713a76800180d0621c)) +- **servarr:** handle servaarr server being unavailable when scanning downloads ([#2358](https://github.com/sct/overseerr/issues/2358)) ([488874f](https://github.com/sct/overseerr/commit/488874fc17e4e4719e90d383b83b1e1a5217213b)) +- sort collection parts by release date ([#2368](https://github.com/sct/overseerr/issues/2368)) ([1b3797c](https://github.com/sct/overseerr/commit/1b3797cf6e6ef6b3d8c81e644382f6e3f68cfaaa)) +- **ui:** request badge styling in request list ([#2302](https://github.com/sct/overseerr/issues/2302)) ([f2375c9](https://github.com/sct/overseerr/commit/f2375c902b79dcb1f349500862775ae57ea7d406)) + +### Features + +- add production countries to movie/TV detail pages ([#2170](https://github.com/sct/overseerr/issues/2170)) ([30b20df](https://github.com/sct/overseerr/commit/30b20df37a9604ba1c066f89e54a5482a09575ea)) +- add quotas, advanced options, and toggles to collection request modal ([#1742](https://github.com/sct/overseerr/issues/1742)) ([af40212](https://github.com/sct/overseerr/commit/af40212a738f8d6d9a5bf26dc20c0c87780d6020)) +- **frontend:** add Discovery+ to network slider ([#2345](https://github.com/sct/overseerr/issues/2345)) ([2ded8f5](https://github.com/sct/overseerr/commit/2ded8f5484168bd7b8f45124d9ebdd296a5708d5)) +- issues ([#2180](https://github.com/sct/overseerr/issues/2180)) ([e402c42](https://github.com/sct/overseerr/commit/e402c42aaa7d795cd724856a2e23615bb1a3695d)) +- **lang:** add Polish display language ([#2261](https://github.com/sct/overseerr/issues/2261)) ([c760cea](https://github.com/sct/overseerr/commit/c760ceaa5f36c77fa3ce320fae1b4597d2d8b976)) +- **lang:** translated using Weblate (Chinese (Traditional)) ([#2272](https://github.com/sct/overseerr/issues/2272)) ([d401e33](https://github.com/sct/overseerr/commit/d401e33249cbbca6e707479e5f0207e298ef3248)) +- **lang:** translations update from Hosted Weblate ([#2277](https://github.com/sct/overseerr/issues/2277)) ([92732fc](https://github.com/sct/overseerr/commit/92732fcb42c56242d16daab00e2d38740b92dea0)) +- **lang:** translations update from Hosted Weblate ([#2315](https://github.com/sct/overseerr/issues/2315)) ([6245be1](https://github.com/sct/overseerr/commit/6245be1e10dda67c869b59522c1290e7c100145f)) +- **lang:** translations update from Hosted Weblate ([#2320](https://github.com/sct/overseerr/issues/2320)) ([68112fa](https://github.com/sct/overseerr/commit/68112faefbd64d5c71d3eff21620767f88ccfc34)) +- **lang:** translations update from Hosted Weblate ([#2325](https://github.com/sct/overseerr/issues/2325)) ([febf067](https://github.com/sct/overseerr/commit/febf0677b880d2fed2822ce510db7cbb0826a920)) +- **lang:** translations update from Hosted Weblate ([#2336](https://github.com/sct/overseerr/issues/2336)) ([3f7ef7a](https://github.com/sct/overseerr/commit/3f7ef7af97a807ef38041f4f2642b565aa33d066)) +- **lang:** translations update from Hosted Weblate ([#2341](https://github.com/sct/overseerr/issues/2341)) ([33fe0bd](https://github.com/sct/overseerr/commit/33fe0bdd1e00da40e85b4e4b4780134b31a105d2)) +- **lang:** translations update from Hosted Weblate ([#2346](https://github.com/sct/overseerr/issues/2346)) ([50dc934](https://github.com/sct/overseerr/commit/50dc9341dd98cb2d8ef3ef6471882a5a9b060afa)) +- **lang:** translations update from Hosted Weblate ([#2364](https://github.com/sct/overseerr/issues/2364)) ([d437cc2](https://github.com/sct/overseerr/commit/d437cc25392e9c0881888371ffabc82892a1b15c)) +- **lang:** translations update from Hosted Weblate ([#2366](https://github.com/sct/overseerr/issues/2366)) ([cc2b2bc](https://github.com/sct/overseerr/commit/cc2b2bc7a8ecd89e1feb38a907596b16df9bf0fc)) +- **lang:** translations update from Hosted Weblate ([#2374](https://github.com/sct/overseerr/issues/2374)) ([b9bedac](https://github.com/sct/overseerr/commit/b9bedac7d7ba85223ecf1d9b93b96e2a490d571a)) +- **lang:** translations update from Weblate ([#2226](https://github.com/sct/overseerr/issues/2226)) ([62b3dc5](https://github.com/sct/overseerr/commit/62b3dc5471c28f4d0e4399cb3bc8bfab94cff5ea)) +- **lang:** translations update from Weblate ([#2241](https://github.com/sct/overseerr/issues/2241)) ([2b0b8e0](https://github.com/sct/overseerr/commit/2b0b8e05d9c95ff9218cea858a920a2815871186)) +- **lang:** translations update from Weblate ([#2244](https://github.com/sct/overseerr/issues/2244)) ([0828b00](https://github.com/sct/overseerr/commit/0828b008badc8b512316799a6787bb7c403658d5)) +- **lang:** translations update from Weblate ([#2247](https://github.com/sct/overseerr/issues/2247)) ([8c49309](https://github.com/sct/overseerr/commit/8c49309c35c31f7bcd0b84b0a307febc16842f68)) +- **lang:** translations update from Weblate ([#2252](https://github.com/sct/overseerr/issues/2252)) ([99d5000](https://github.com/sct/overseerr/commit/99d50004e58f6b4594df0a171f6bc668635ec50c)) +- **lang:** translations update from Weblate ([#2265](https://github.com/sct/overseerr/issues/2265)) ([b1b367a](https://github.com/sct/overseerr/commit/b1b367aac625ed3eb865832c94c2352e5a5c40f5)) +- **notif:** 4K media notifications ([#2324](https://github.com/sct/overseerr/issues/2324)) ([88a8c1a](https://github.com/sct/overseerr/commit/88a8c1aa596e1113d6da52e5e8cbe443abc6384f)) +- **notif:** add Pushbullet and Pushover agents to user notification settings ([#1740](https://github.com/sct/overseerr/issues/1740)) ([aeb7a48](https://github.com/sct/overseerr/commit/aeb7a48d72cec3fa2b857030aad3eaa0a457a896)) +- **notif:** issue notifications ([#2242](https://github.com/sct/overseerr/issues/2242)) ([c9ffac3](https://github.com/sct/overseerr/commit/c9ffac33f7c04d926f8c45295703689d42fe87af)) +- **search:** close search bar when hitting return ([#2260](https://github.com/sct/overseerr/issues/2260)) ([b423dc1](https://github.com/sct/overseerr/commit/b423dc167d12f0ba49f902876bceb2e876e35f58)) +- **ui:** allow admins to edit & approve request from advanced request modal ([#2067](https://github.com/sct/overseerr/issues/2067)) ([340f1a2](https://github.com/sct/overseerr/commit/340f1a211952bd2e8f40f0ea4622b52dbe934e85)) + +# [1.27.0](https://github.com/sct/overseerr/compare/v1.26.1...v1.27.0) (2021-10-19) + +### Bug Fixes + +- **api:** return queried user's requests instead of own requests ([#2174](https://github.com/sct/overseerr/issues/2174)) ([0edb1f4](https://github.com/sct/overseerr/commit/0edb1f452b6ff4a49ae2bde15f7273769788cf4f)) +- **api:** use query builder for user requests endpoint ([#2119](https://github.com/sct/overseerr/issues/2119)) ([a20f395](https://github.com/sct/overseerr/commit/a20f395c94c97dd7ddbc25590f15def2c9bf13c9)) +- apply request overrides iff override & selected servers match ([#2164](https://github.com/sct/overseerr/issues/2164)) ([50ce198](https://github.com/sct/overseerr/commit/50ce198471b1a3777a183d68904bbfb39ebd4523)) +- **email:** do not attempt to display logo if app URL not configured ([#2125](https://github.com/sct/overseerr/issues/2125)) ([b3b421a](https://github.com/sct/overseerr/commit/b3b421a67408a4a48d23c15341fcdf7aaf19b25a)) +- **frontend:** notification type validation ([#2207](https://github.com/sct/overseerr/issues/2207)) ([2f204b9](https://github.com/sct/overseerr/commit/2f204b995269a53ae36f2a8733f27ae6ab70da5a)) +- **scripts:** update migration scripts ([#2208](https://github.com/sct/overseerr/issues/2208)) [skip ci] ([d0ac74e](https://github.com/sct/overseerr/commit/d0ac74ea4bbfcf3d25d30cbd422d9df1c1259a18)) +- **ui:** refinements for 'About' page ([#2173](https://github.com/sct/overseerr/issues/2173)) ([084a842](https://github.com/sct/overseerr/commit/084a842a4f9b6caaed22edbe77bc9e414bc1f387)) + +### Features + +- display release dates for theatrical, digital, and physical release types ([#1492](https://github.com/sct/overseerr/issues/1492)) ([a4dca23](https://github.com/sct/overseerr/commit/a4dca2356b7605026f7bc45b691496e765c3328c)) +- dynamically fetch login screen backdrop images ([#2206](https://github.com/sct/overseerr/issues/2206)) ([3486d0b](https://github.com/sct/overseerr/commit/3486d0bf5520cbdff60bd8fd023caed76c452973)) +- **frontend:** add Hulu to network slider ([#2204](https://github.com/sct/overseerr/issues/2204)) ([1e402f7](https://github.com/sct/overseerr/commit/1e402f710b53c11855aab0abdb4b12c51c30b022)) +- **jobs:** allow modifying job schedules ([#1440](https://github.com/sct/overseerr/issues/1440)) ([82614ca](https://github.com/sct/overseerr/commit/82614ca4410782a12d65b4c0a6526ff064be1241)) +- **lang:** add Czech and Danish display languages ([#2176](https://github.com/sct/overseerr/issues/2176)) ([8d8db6c](https://github.com/sct/overseerr/commit/8d8db6cf5d98d4e498a31db339d02f8a98057c8d)) +- **lang:** translations update from Weblate ([#2101](https://github.com/sct/overseerr/issues/2101)) ([c73cf7b](https://github.com/sct/overseerr/commit/c73cf7b19cbc19e97a777c0facb9264fb0113093)) +- **lang:** translations update from Weblate ([#2179](https://github.com/sct/overseerr/issues/2179)) ([e3312ce](https://github.com/sct/overseerr/commit/e3312cef33821c8cb76a4a63bd565c78d67b3e0b)) +- **lang:** translations update from Weblate ([#2185](https://github.com/sct/overseerr/issues/2185)) ([dce10f7](https://github.com/sct/overseerr/commit/dce10f743f52cb04036e2cdaee280e26a81b253b)) +- **lang:** translations update from Weblate ([#2202](https://github.com/sct/overseerr/issues/2202)) ([492d8e3](https://github.com/sct/overseerr/commit/492d8e3daa5fb99aa9df2a18978085d5ddd581e7)) +- **lang:** translations update from Weblate ([#2210](https://github.com/sct/overseerr/issues/2210)) ([0a6ef6c](https://github.com/sct/overseerr/commit/0a6ef6cc81376f7a02f1483109be7ae4ab851c48)) +- **plex-scan:** plex scanner improvements ([#2105](https://github.com/sct/overseerr/issues/2105)) ([afda9c7](https://github.com/sct/overseerr/commit/afda9c7dc222137b0e6654a6beb4737cf2c1752e)) +- **servarr:** auto fill base url when testing service if missing ([#1995](https://github.com/sct/overseerr/issues/1995)) ([739f667](https://github.com/sct/overseerr/commit/739f667b54d8dec258b74d0cd8fd8b3b88dcf8d5)) +- **ui:** link processing/requested status badges to service URL ([#1761](https://github.com/sct/overseerr/issues/1761)) ([032c14a](https://github.com/sct/overseerr/commit/032c14a22680f62f8106943297b081b68645ce61)) + +## [1.26.1](https://github.com/sct/overseerr/compare/v1.26.0...v1.26.1) (2021-09-20) + +### Bug Fixes + +- **rt-api:** correctly format movie urls ([4c6009b](https://github.com/sct/overseerr/commit/4c6009bc2c3ff5f657a806363e3bdf7cd83d4261)) + +# [1.26.0](https://github.com/sct/overseerr/compare/v1.25.0...v1.26.0) (2021-09-19) + +### Bug Fixes + +- **email:** omit links when application URL is not configured ([#1806](https://github.com/sct/overseerr/issues/1806)) ([1133a34](https://github.com/sct/overseerr/commit/1133a34ffdf95c4d036be0264fe7f94f64007e8f)) +- **lang:** minor changes to password reset strings ([#1798](https://github.com/sct/overseerr/issues/1798)) ([a41245c](https://github.com/sct/overseerr/commit/a41245c703688743ec24f9b4a53e70f3340daa0f)) +- **notif:** truncate media overviews ([#1800](https://github.com/sct/overseerr/issues/1800)) ([42e45f3](https://github.com/sct/overseerr/commit/42e45f38e5ede7df0fc4bdb20a970917b2361569)) +- **plex:** do not fail to scan empty libraries ([#1771](https://github.com/sct/overseerr/issues/1771)) ([6789b87](https://github.com/sct/overseerr/commit/6789b8701cb644d9a3f1384f30b3dff707201ef7)) +- **quota:** block multi-season requests that would exceed a user's quota ([#1874](https://github.com/sct/overseerr/issues/1874)) ([8a55f85](https://github.com/sct/overseerr/commit/8a55f85d3ef14ccb83b139acb35d0746431637be)) +- **rt-api:** use rotten-tomatoes 2.0 search api for movies ([a11bb49](https://github.com/sct/overseerr/commit/a11bb49663ec345332c4dd70ddbb49ce230b5c3c)) +- **ui:** center logo on password reset pages ([#1807](https://github.com/sct/overseerr/issues/1807)) ([b8e82b5](https://github.com/sct/overseerr/commit/b8e82b5b4d3cb49ec372e3dce3cd89dff440ffd0)) +- **ui:** change sidebar breakpoint to lg ([#1972](https://github.com/sct/overseerr/issues/1972)) ([70bd9e9](https://github.com/sct/overseerr/commit/70bd9e9308b607206b60a2a36a511de6e397a3db)) +- **ui:** do not allow submission of invalid form inputs ([#1799](https://github.com/sct/overseerr/issues/1799)) ([910d00c](https://github.com/sct/overseerr/commit/910d00c19522a70125bfb5e5081a7ef4000e7f54)) +- **ui:** do not display negative remaining quota ([#1859](https://github.com/sct/overseerr/issues/1859)) ([3841fb0](https://github.com/sct/overseerr/commit/3841fb06ebe1e09250362cc6cb401fdca12eef7f)) +- **ui:** fix notifications settings buttons overflowing ([#1911](https://github.com/sct/overseerr/issues/1911)) ([0ce18b2](https://github.com/sct/overseerr/commit/0ce18b21ca547af6c083c3f248e22b7daf92aef0)) +- **ui:** sort 'Request As' user dropdown by display name ([#2099](https://github.com/sct/overseerr/issues/2099)) ([bb09f8e](https://github.com/sct/overseerr/commit/bb09f8eaf70f6d0c981f31bd5f3c8afb2fe101ab)) +- **webpush:** load user in push sub query ([#1894](https://github.com/sct/overseerr/issues/1894)) ([6f2db6a](https://github.com/sct/overseerr/commit/6f2db6a6ccf299262cf86d91acf639b921f28286)) +- correct logo filename ([#1805](https://github.com/sct/overseerr/issues/1805)) ([f95be83](https://github.com/sct/overseerr/commit/f95be832f95a68b114ff24a65ffa0ebbd71b4121)) + +### Features + +- list streaming providers on movie/TV detail pages ([#1778](https://github.com/sct/overseerr/issues/1778)) ([98ece67](https://github.com/sct/overseerr/commit/98ece67655a5dffe894974e337a3603afeed0236)) +- **lang:** add Simplified Chinese display language ([#2032](https://github.com/sct/overseerr/issues/2032)) ([590ea7e](https://github.com/sct/overseerr/commit/590ea7e40460e381377b212d00869f191908b41f)) +- **lang:** translated using Weblate (German) ([#1791](https://github.com/sct/overseerr/issues/1791)) ([15f7941](https://github.com/sct/overseerr/commit/15f7941269075b7e12de8bbc0f98418af70df380)) +- **lang:** translations update from Weblate ([#1772](https://github.com/sct/overseerr/issues/1772)) ([6a75a05](https://github.com/sct/overseerr/commit/6a75a05c2348455d5374132a2574d988879d543a)) +- **lang:** translations update from Weblate ([#1796](https://github.com/sct/overseerr/issues/1796)) ([57b52fc](https://github.com/sct/overseerr/commit/57b52fc9cccd3fac93cdb68e36cf652ddbcdf86c)) +- **lang:** translations update from Weblate ([#1910](https://github.com/sct/overseerr/issues/1910)) ([fe89fd5](https://github.com/sct/overseerr/commit/fe89fd5f12460cb1b3acb09fb16b62497ef50f5f)) +- **lang:** translations update from Weblate ([#2058](https://github.com/sct/overseerr/issues/2058)) ([db42c46](https://github.com/sct/overseerr/commit/db42c4678145d2a9676aa71b6773607b696f7cea)) +- **notif:** Restyle HTML email notifications Part 2 ([#1917](https://github.com/sct/overseerr/issues/1917)) ([376149d](https://github.com/sct/overseerr/commit/376149d6ebb4db28d949391115f475afdd4e7d48)) +- **ui:** add 'show more/less...' for studios on movie details page ([#1770](https://github.com/sct/overseerr/issues/1770)) ([680ea0c](https://github.com/sct/overseerr/commit/680ea0c87a9ae143413354680c421d62bccd869d)) +- new logo, who dis? ([#1802](https://github.com/sct/overseerr/issues/1802)) ([beb5637](https://github.com/sct/overseerr/commit/beb5637d9f5c01d773eaee93035b7c195c2ae5f2)) + +# [1.25.0](https://github.com/sct/overseerr/compare/v1.24.0...v1.25.0) (2021-06-10) + +### Bug Fixes + +- **frontend:** add missing route guards to settings pages ([#1700](https://github.com/sct/overseerr/issues/1700)) ([78fc1f7](https://github.com/sct/overseerr/commit/78fc1f7b7d9ef912077066a3605fed6237fb4c8a)) +- **locale:** set locale based on user settings upon login ([#1584](https://github.com/sct/overseerr/issues/1584)) ([f48312e](https://github.com/sct/overseerr/commit/f48312e833ed5d48c41179d0eadbc66d45486d8a)) +- **notif:** include year in Media Available notifications ([#1672](https://github.com/sct/overseerr/issues/1672)) ([11aa712](https://github.com/sct/overseerr/commit/11aa712eb0e8796874c96fbcc9b51b523108e2d4)) +- **plex:** disable library sync if Plex not configured, and disable scan if no libraries ([#1764](https://github.com/sct/overseerr/issues/1764)) ([22238fe](https://github.com/sct/overseerr/commit/22238fe4f711267d001be95942b3151c536e0c18)) +- **plex:** do not fail to import Plex users when Plex Home has managed users ([#1699](https://github.com/sct/overseerr/issues/1699)) ([310cdb3](https://github.com/sct/overseerr/commit/310cdb36df1601bca5e57f0bc796c44111b8435f)) +- **plex:** sync libraries after saving settings ([#1592](https://github.com/sct/overseerr/issues/1592)) ([9749d72](https://github.com/sct/overseerr/commit/9749d723fc0a282b291c06ee68a6e174dcec1c5b)) +- **requests:** appropriately set modifiedBy user for new requests ([#1684](https://github.com/sct/overseerr/issues/1684)) ([a3f04b3](https://github.com/sct/overseerr/commit/a3f04b3f3522d46dc65178bddd1e986426e48050)) +- **requests:** do not prevent duplicate requests if other requests are declined ([de0759c](https://github.com/sct/overseerr/commit/de0759c26a9e857e2b8d7244673625fc79ee4660)) +- **requests:** prevent duplicate movie requests ([126d866](https://github.com/sct/overseerr/commit/126d8665ee2808fc0bc37df4ca61f3e63be096e2)) +- check that application URL and email agent are configured for password reset/generation ([#1724](https://github.com/sct/overseerr/issues/1724)) ([091d66a](https://github.com/sct/overseerr/commit/091d66a1928d3c69a11eab2a789b4639b5ba9817)) +- correctly display error messages ([#1653](https://github.com/sct/overseerr/issues/1653)) ([31cb717](https://github.com/sct/overseerr/commit/31cb7176d286e706575a2dc8003df13f3e737106)) +- handle null values in User email transform ([#1712](https://github.com/sct/overseerr/issues/1712)) ([4a042f1](https://github.com/sct/overseerr/commit/4a042f12be6510ee47de3a7e025497f8d132d6a1)) +- **lang:** only set locale once at page load and move subsequent updates back into Layout ([14756f4](https://github.com/sct/overseerr/commit/14756f4b208c5b201a6e632b43e7a21c5bec6f9c)), closes [#1662](https://github.com/sct/overseerr/issues/1662) +- **locale:** properly restore display language upon page refresh ([#1646](https://github.com/sct/overseerr/issues/1646)) ([e85d1ce](https://github.com/sct/overseerr/commit/e85d1ce94ec45d8f5d086722cfd88e0e2c5b4bb6)) +- **notifications:** default webpush notification agent to enabled for users for settings response ([7520e24](https://github.com/sct/overseerr/commit/7520e24e9287e214dd31224f1201e9b6385fd567)), closes [#1663](https://github.com/sct/overseerr/issues/1663) +- **quotas:** do not count already-requested seasons when editing TV request ([#1649](https://github.com/sct/overseerr/issues/1649)) ([808ccf1](https://github.com/sct/overseerr/commit/808ccf1c6975f853db6dc89f4d9f1f5488dbaae3)) +- **requests:** remove requestedBy user param from existing movie request check ([#1569](https://github.com/sct/overseerr/issues/1569)) ([788f3dc](https://github.com/sct/overseerr/commit/788f3dc435ae224fcc4d4cb2890b1b9b494c64e8)) +- **sensitiveinput:** do not capture enter key input ([#1650](https://github.com/sct/overseerr/issues/1650)) ([bb8d14b](https://github.com/sct/overseerr/commit/bb8d14b5ffd840eff0c2a00e1b5d318677a5ca5f)) +- **sonarr:** do not mark media as failed if there is no season data on TVDB ([#1691](https://github.com/sct/overseerr/issues/1691)) ([0cd7fa0](https://github.com/sct/overseerr/commit/0cd7fa0f1a00d129339be13550a4f694c820a0e9)) +- **tv:** don't show duplicate air date ([#1666](https://github.com/sct/overseerr/issues/1666)) ([e1f5feb](https://github.com/sct/overseerr/commit/e1f5febe7bbf27e77b6f5d057c2c3f7e22898734)) +- **ui:** add clarification to user settings ([#1644](https://github.com/sct/overseerr/issues/1644)) ([2ef57e9](https://github.com/sct/overseerr/commit/2ef57e9b1a5b4d0a1499921f4e26b0b0712d7ded)) +- **ui:** correct horizontal overflow behavior of settings tabs ([#1667](https://github.com/sct/overseerr/issues/1667)) ([e6d5f0a](https://github.com/sct/overseerr/commit/e6d5f0abfebdc24f25d08822b57a8eb7bc48e137)) +- **ui:** hide advanced request options when there is only one choice ([#1591](https://github.com/sct/overseerr/issues/1591)) ([6b26188](https://github.com/sct/overseerr/commit/6b26188d888a1f80bd36a1968e41333bab2af794)) +- **ui:** improve QuotaSelector display of unlimited and singular values ([#1704](https://github.com/sct/overseerr/issues/1704)) ([59b2ec1](https://github.com/sct/overseerr/commit/59b2ec11fa8868bf6873ffa80f4999ae10d65637)) +- perform case-insensitive match for local user email addresses ([#1633](https://github.com/sct/overseerr/issues/1633)) ([928b8a7](https://github.com/sct/overseerr/commit/928b8a71cf361b7bc2b8957c621f5b66c4657b1e)) +- **ui:** apply pointer cursor style for clickable status badges ([#1632](https://github.com/sct/overseerr/issues/1632)) ([6968caa](https://github.com/sct/overseerr/commit/6968caa35a70c172bdd57c984fde6cb6a04a1470)) +- **ui:** remove delete button from request cards ([#1635](https://github.com/sct/overseerr/issues/1635)) ([6b37242](https://github.com/sct/overseerr/commit/6b37242a3f5a3b332d259f4814d235d751ae2491)) +- switch PGP regex to span multiple lines ([#1598](https://github.com/sct/overseerr/issues/1598)) ([d0703aa](https://github.com/sct/overseerr/commit/d0703aa37772759e8e28b5da7187e97e7aadc495)) +- **ui:** hide Plex alert after setup and add local login warning to local user modal ([#1600](https://github.com/sct/overseerr/issues/1600)) ([694d0ff](https://github.com/sct/overseerr/commit/694d0ffcf6b3e3fa00175400fa4217a7d6eb787f)) + +### Features + +- **lang:** add Greek display language ([#1605](https://github.com/sct/overseerr/issues/1605)) ([2241564](https://github.com/sct/overseerr/commit/22415642e8602809e3507e5b13dc2f8de3000003)) +- **lang:** translations update from Weblate ([#1585](https://github.com/sct/overseerr/issues/1585)) ([361ea77](https://github.com/sct/overseerr/commit/361ea77588db3dc04a51dd3a62c73ae1297cdce2)) +- **lang:** translations update from Weblate ([#1603](https://github.com/sct/overseerr/issues/1603)) ([2efa7fa](https://github.com/sct/overseerr/commit/2efa7faf20d05a5fc423e0151c6b46fe6212d096)) +- **lang:** translations update from Weblate ([#1639](https://github.com/sct/overseerr/issues/1639)) ([d22400d](https://github.com/sct/overseerr/commit/d22400dbc9320743498eeb8e6a4dcbccf1a4d52d)) +- **lang:** translations update from Weblate ([#1676](https://github.com/sct/overseerr/issues/1676)) ([8a80571](https://github.com/sct/overseerr/commit/8a805716e3e34ae8d081ad47f9d4cd68f88b0116)) +- **lang:** translations update from Weblate ([#1703](https://github.com/sct/overseerr/issues/1703)) ([6a3649f](https://github.com/sct/overseerr/commit/6a3649f620e518ff07a48c17ce1182aaedff398a)) +- **lang:** translations update from Weblate ([#1727](https://github.com/sct/overseerr/issues/1727)) ([60c3ced](https://github.com/sct/overseerr/commit/60c3ced9e2466568eecde93c88410c87ff0b796f)) +- **lang:** translations update from Weblate ([#1746](https://github.com/sct/overseerr/issues/1746)) ([37a4df6](https://github.com/sct/overseerr/commit/37a4df646cc3e3101360037f1b6f061a734eb5e2)) +- **lang:** translations update from Weblate ([#1768](https://github.com/sct/overseerr/issues/1768)) ([dedf95e](https://github.com/sct/overseerr/commit/dedf95e574a15a708866c381353e58ce3b3a1a61)) +- add display name to create local user modal ([#1631](https://github.com/sct/overseerr/issues/1631)) ([44c3edb](https://github.com/sct/overseerr/commit/44c3edb98568ba15eb525e665115429cfb15d28b)) +- allow users to select notification types ([#1512](https://github.com/sct/overseerr/issues/1512)) ([e605989](https://github.com/sct/overseerr/commit/e60598905b2d6eef7c1872d0c9e92e6d70508ae8)) +- **notif:** prevent manage-request users receiving auto-approve notif from their requests ([#1707](https://github.com/sct/overseerr/issues/1707)) ([#1709](https://github.com/sct/overseerr/issues/1709)) ([9ead8bb](https://github.com/sct/overseerr/commit/9ead8bb1f1680b522550f963502c83e2f99d1e96)) +- **plex:** add support for custom Plex Web App URLs ([#1581](https://github.com/sct/overseerr/issues/1581)) ([a640a91](https://github.com/sct/overseerr/commit/a640a91390f1411637ad379a8253002fdf60480f)) +- **pwa:** add notification badge icon ([#1695](https://github.com/sct/overseerr/issues/1695)) ([9b3b6a9](https://github.com/sct/overseerr/commit/9b3b6a9170b25209e54c74aa9e96659bc2d19edd)) +- **ui:** request list item & request card improvements ([#1532](https://github.com/sct/overseerr/issues/1532)) ([d7b9b1a](https://github.com/sct/overseerr/commit/d7b9b1a525ec6d1d81ad6fe4e55994dd8428988f)) +- **webpush:** add warning to web push settings re: HTTPS requirement ([#1599](https://github.com/sct/overseerr/issues/1599)) ([0c4fb64](https://github.com/sct/overseerr/commit/0c4fb6446be425905a120df5be9a28b052e884c0)) + +### Reverts + +- **deps:** revert back to typeorm 0.2.32 ([4368c3a](https://github.com/sct/overseerr/commit/4368c3aa4f88425ec08f3b555419e572cfa320e3)) +- **deps:** use 10.1.3 until css import issue is resolved ([2254248](https://github.com/sct/overseerr/commit/2254248abc0f2051a9dd28d9663c7ab1d0b547b6)) +- **requests:** go back to old modifiedBy request values for now ([0918b25](https://github.com/sct/overseerr/commit/0918b254132b0541999486e1f0679d0c0cd65864)) + +# [1.24.0](https://github.com/sct/overseerr/compare/v1.23.2...v1.24.0) (2021-05-05) + +### Bug Fixes + +- **api:** do not try to transform empty values passed to user notificationTypes ([ef3f977](https://github.com/sct/overseerr/commit/ef3f9778aa81f8ed39dcd835d63d94f2248e0204)), closes [#1501](https://github.com/sct/overseerr/issues/1501) +- **backend:** properly set request media status ([#1541](https://github.com/sct/overseerr/issues/1541)) ([b7b55e2](https://github.com/sct/overseerr/commit/b7b55e275cb2f1f61c3057cb8ab4cb1027f6356d)) +- **css:** don't target button globally ([#1510](https://github.com/sct/overseerr/issues/1510)) ([f78b9c1](https://github.com/sct/overseerr/commit/f78b9c1ca9648eb10b010e526d9b9db09648b154)) +- **css:** fix cog icon size on media detail pages ([#1520](https://github.com/sct/overseerr/issues/1520)) ([26ddc03](https://github.com/sct/overseerr/commit/26ddc03b2c01b343c24f1c359b78c587310cc747)) +- **email:** parse sender hostname from application URL ([#1518](https://github.com/sct/overseerr/issues/1518)) ([3baa55c](https://github.com/sct/overseerr/commit/3baa55c690dd9ba39768b8b271595cb6b09fe6da)) +- **lang:** correct overwritten email toast strings ([11a5e8d](https://github.com/sct/overseerr/commit/11a5e8d95bc2a2f16adf1e48d2ef38b508a6ace5)) +- **locale:** default user locale should be the server setting ([#1574](https://github.com/sct/overseerr/issues/1574)) ([549103f](https://github.com/sct/overseerr/commit/549103f6f6d5624201e425df7d7814f0f67863b9)) +- **pwa:** add Discover shortcut and fix/optimize icons ([#1525](https://github.com/sct/overseerr/issues/1525)) ([e1dc62b](https://github.com/sct/overseerr/commit/e1dc62b0a5b64202701aff821837ed11dd3f12db)) +- **radarr:** only process Radarr movies which are either monitored or downloaded ([#1511](https://github.com/sct/overseerr/issues/1511)) ([85899ab](https://github.com/sct/overseerr/commit/85899ab49a27542390e91443531905737224338d)) +- **ui:** add missing margins on button SVGs on Plex Settings page ([#1546](https://github.com/sct/overseerr/issues/1546)) ([5e588be](https://github.com/sct/overseerr/commit/5e588be8127b50dd83477f7f3a65f18de774e8af)) +- **ui:** add user profile links to RequestBlock and change 'ETA' string in DownloadBlock ([#1551](https://github.com/sct/overseerr/issues/1551)) ([e4d0029](https://github.com/sct/overseerr/commit/e4d0029f7b4245b8606e2447c54629def40c7761)) +- **ui:** apply rounded-l-only to SensitiveInput textareas and increase visible text input area ([#1561](https://github.com/sct/overseerr/issues/1561)) ([1123fce](https://github.com/sct/overseerr/commit/1123fce089b86251dcafebf77743d60a6e396bee)) +- **ui:** correct RegionSelector z-index ([#1567](https://github.com/sct/overseerr/issues/1567)) ([e912a00](https://github.com/sct/overseerr/commit/e912a00880f856fa9621e8587ef1cc6513a3d49c)) +- **ui:** correct toasts being in the wrong position on smaller screens ([2ecd9d7](https://github.com/sct/overseerr/commit/2ecd9d7b1391b8fc83e9c12a18bab105e7148f0f)) +- **ui:** default to text input type for SensitiveInputs ([#1568](https://github.com/sct/overseerr/issues/1568)) ([e2acf88](https://github.com/sct/overseerr/commit/e2acf8887cb0456c80308bd1b7f3bbe1930e8cff)) +- **ui:** explicitly specify width/height of Listbox dropdown icon ([#1514](https://github.com/sct/overseerr/issues/1514)) ([802e40a](https://github.com/sct/overseerr/commit/802e40a5dfa00f897f9d5a741718a319f74ff030)) +- **ui:** improve form usability ([#1563](https://github.com/sct/overseerr/issues/1563)) ([26580ea](https://github.com/sct/overseerr/commit/26580eaa218702bc5841718310e340d049c50332)) +- **ui:** show warning if user has both a default non-4K server and a non-default 4K server ([#1478](https://github.com/sct/overseerr/issues/1478)) ([4faddf3](https://github.com/sct/overseerr/commit/4faddf3810e20851c7ae1251ff0187fa13d7b0f6)) +- **webpush:** only prompt user to allow notifications if enabled in user settings ([#1552](https://github.com/sct/overseerr/issues/1552)) ([b05b177](https://github.com/sct/overseerr/commit/b05b177776a5d22bf3b5e93bad4358f4007b879a)) +- correctly fall back to English name in LanguageSelector ([#1537](https://github.com/sct/overseerr/issues/1537)) ([189313e](https://github.com/sct/overseerr/commit/189313e94a16e694d192d157642d77f664fd709b)) +- do not set locale when modifying other users ([#1499](https://github.com/sct/overseerr/issues/1499)) ([4858771](https://github.com/sct/overseerr/commit/48587719e9474139c7bbc2970b1c7d1d17b78a81)) + +### Features + +- **email:** replace 'Enable SSL' setting with more descriptive/clear 'Encryption Method' setting ([#1549](https://github.com/sct/overseerr/issues/1549)) ([69ab7cc](https://github.com/sct/overseerr/commit/69ab7cc660bea43b70bdb646eabd3866c1b5a90f)) +- **inputs:** add support for toggling security on input fields ([#1404](https://github.com/sct/overseerr/issues/1404)) ([4fd452d](https://github.com/sct/overseerr/commit/4fd452dd1880f597a0acda812d567e7cb6c16d83)) +- **lang:** translated using Weblate (Spanish) ([#1553](https://github.com/sct/overseerr/issues/1553)) ([e3d5e33](https://github.com/sct/overseerr/commit/e3d5e33ec3e43d36ec832d6ca47f330fc7675088)) +- **lang:** translations update from Weblate ([#1497](https://github.com/sct/overseerr/issues/1497)) ([9a95a07](https://github.com/sct/overseerr/commit/9a95a073916c9968b8ef348d0805d77400ea203a)) +- **lang:** translations update from Weblate ([#1527](https://github.com/sct/overseerr/issues/1527)) ([1a6d4bd](https://github.com/sct/overseerr/commit/1a6d4bddc016f4aaad83b945e103b19be4d0da31)) +- **lang:** translations update from Weblate ([#1558](https://github.com/sct/overseerr/issues/1558)) ([6c9991d](https://github.com/sct/overseerr/commit/6c9991d474a5cd95d9a0a10104bd79d8a9f3ada9)) +- **lang:** translations update from Weblate ([#1566](https://github.com/sct/overseerr/issues/1566)) ([93c441e](https://github.com/sct/overseerr/commit/93c441ef6665291ca3698368e4b093c843726036)) +- add server default locale setting ([#1536](https://github.com/sct/overseerr/issues/1536)) ([f256a44](https://github.com/sct/overseerr/commit/f256a444c57f2d92c1c4918d4ff6e223ef85ecd2)) +- **notif:** add LunaSea agent ([#1495](https://github.com/sct/overseerr/issues/1495)) ([4e6fb00](https://github.com/sct/overseerr/commit/4e6fb00a4a59545817add1544c0b1555078809a4)) +- **notif:** show success/failure toast for test notifications ([#1442](https://github.com/sct/overseerr/issues/1442)) ([079645c](https://github.com/sct/overseerr/commit/079645c2c74edfb7e4f583de2ac72bb9824f6524)) +- **perms:** add separate REQUEST_MOVIE and REQUEST_TV permissions ([#1474](https://github.com/sct/overseerr/issues/1474)) ([91b9e0f](https://github.com/sct/overseerr/commit/91b9e0f67996a442b5c0117fe09e2d69c163fafb)) +- **pwa:** add shortcuts to PWA ([#1509](https://github.com/sct/overseerr/issues/1509)) ([ed99e49](https://github.com/sct/overseerr/commit/ed99e4976dc2700fe84c70af4887c1a431bba92c)) +- add option to only allow Plex sign-in from existing users ([#1496](https://github.com/sct/overseerr/issues/1496)) ([db49b20](https://github.com/sct/overseerr/commit/db49b2024d399d90f2d1500b262374efc42f333c)) +- PWA Support ([#1488](https://github.com/sct/overseerr/issues/1488)) ([28830d4](https://github.com/sct/overseerr/commit/28830d4ef809efa92a5879a81cac11ff52ea3d1f)) + +## [1.23.2](https://github.com/sct/overseerr/compare/v1.23.1...v1.23.2) (2021-04-21) + +### Bug Fixes + +- **lang:** add missing '4K' from singular case of approve/deny 4K request strings ([#1481](https://github.com/sct/overseerr/issues/1481)) ([a822b01](https://github.com/sct/overseerr/commit/a822b019220e86e362a2570e7024289450b4ed46)) +- **ui:** change 'Disable Auto-Search' checkbox to 'Enable Automatic Search' ([#1476](https://github.com/sct/overseerr/issues/1476)) ([1a311d2](https://github.com/sct/overseerr/commit/1a311d211d78731c9089e66ed5387c1b5afe33c0)) +- better error message when creating a user with an existing email ([f13f1c9](https://github.com/sct/overseerr/commit/f13f1c94515b5bd51382fa18ad96a2ccfd06e50d)), closes [#1441](https://github.com/sct/overseerr/issues/1441) +- set editRequest attribute as necessary, allow users to edit their own pending requests, and show 'View Request' button on series pages ([#1446](https://github.com/sct/overseerr/issues/1446)) ([89455ad](https://github.com/sct/overseerr/commit/89455ad9b783d04d993a0009c351b1096f2b222e)) +- **api:** add check for 4K request perms to request creation endpoint ([#1450](https://github.com/sct/overseerr/issues/1450)) ([4449241](https://github.com/sct/overseerr/commit/4449241a8f63fdaeaa4995aa7ec34127c322b9dd)) +- **notif:** include year in notifications ([#1439](https://github.com/sct/overseerr/issues/1439)) ([4e98f56](https://github.com/sct/overseerr/commit/4e98f567534a650e26b0244990b7ca549cecbe89)) +- **plex:** add support for plex.direct URLs ([#1437](https://github.com/sct/overseerr/issues/1437)) ([db07770](https://github.com/sct/overseerr/commit/db077700e42ab1d2c870213fd55bbdee74002775)) +- **radarr:** search in addition to monitoring existing movies ([#1449](https://github.com/sct/overseerr/issues/1449)) ([3ae7d00](https://github.com/sct/overseerr/commit/3ae7d0098b225562499d7c8a74b8b6c3e8893ad9)) +- **ui:** adjust user list buttons on mobile ([#1452](https://github.com/sct/overseerr/issues/1452)) ([5d1b741](https://github.com/sct/overseerr/commit/5d1b741f55665c528e299a09464dff6d66f72666)) +- **ui:** align icons in user dropdown ([eb5d152](https://github.com/sct/overseerr/commit/eb5d1528869959cdf642e6fefc1a8f4dcf51b84e)) + +## [1.23.1](https://github.com/sct/overseerr/compare/v1.23.0...v1.23.1) (2021-04-16) + +### Bug Fixes + +- **api:** correctly check if update is available for release versions ([190cbd6](https://github.com/sct/overseerr/commit/190cbd6559c51a02ec09b267891f3033add6afc8)) + +# [1.23.0](https://github.com/sct/overseerr/compare/v1.22.0...v1.23.0) (2021-04-16) + +### Bug Fixes + +- **api:** allow server owner to delete other admin accounts ([2ac6fe7](https://github.com/sct/overseerr/commit/2ac6fe7f6d666d64228d11cde24865acc54c7ce7)) +- **backend:** do not log error when user has no server access ([#1419](https://github.com/sct/overseerr/issues/1419)) ([fc14037](https://github.com/sct/overseerr/commit/fc14037ec1c0b7450d892fa9be8176f5b9ff9d73)) +- **frontend:** add crossorigin attribute to webmanifest link ([#1376](https://github.com/sct/overseerr/issues/1376)) ([82ca2f5](https://github.com/sct/overseerr/commit/82ca2f59349407e3b1b5cd4f321e196f37044df0)) +- **frontend:** autofill with Plex server address ([#1381](https://github.com/sct/overseerr/issues/1381)) ([d9e314b](https://github.com/sct/overseerr/commit/d9e314bad295463d26d8ffe92728f3b5eee4ad05)) +- **frontend:** handle media items/requests no longer having a valid tmdb id ([b5ac2f5](https://github.com/sct/overseerr/commit/b5ac2f5a2c5dda808eca177359f125d6e03d1b0f)), closes [#517](https://github.com/sct/overseerr/issues/517) +- **lang:** remove unused strings & correct manageModalNoRequests strings ([#1413](https://github.com/sct/overseerr/issues/1413)) ([190a5c0](https://github.com/sct/overseerr/commit/190a5c0723d4aeafc4ad6103d52c2042a4eaed0e)) +- **plex:** do not use SSL for local servers ([#1418](https://github.com/sct/overseerr/issues/1418)) ([9233fc0](https://github.com/sct/overseerr/commit/9233fc078579df8a193344ba45bafb0d5c2cb9af)) +- **plex:** use server 'address' returned by Plex API ([#1379](https://github.com/sct/overseerr/issues/1379)) ([33542c9](https://github.com/sct/overseerr/commit/33542c9b2dc53b1e036a7d9571cf467c3d3dc8af)) +- **quotas:** Time value of a quota was being ignored ([d3c6bc1](https://github.com/sct/overseerr/commit/d3c6bc1619c39b1e6225d405efaad5df99a27406)) +- **ui:** allow canceling from request list & hide edit button for own requests ([#1401](https://github.com/sct/overseerr/issues/1401)) ([bed850d](https://github.com/sct/overseerr/commit/bed850dce9ad0d0b52c3c628225aea938164c38b)) +- **ui:** close sidebar on mobile when clicking version status ([ad67381](https://github.com/sct/overseerr/commit/ad673813976669797202c2cefc50274aca84989d)) +- **ui:** correctly set autocomplete attribute for password fields ([#1430](https://github.com/sct/overseerr/issues/1430)) ([4b5e355](https://github.com/sct/overseerr/commit/4b5e355df9e291a5cb550483c7dad6c43f03d3a7)) +- **ui:** dim password field when password generation option is selected ([#1427](https://github.com/sct/overseerr/issues/1427)) ([e8bbd44](https://github.com/sct/overseerr/commit/e8bbd4497a5eab6357fa7b37c9906285b3d1f64f)) +- **ui:** hide alert when email notifs are already configured ([#1335](https://github.com/sct/overseerr/issues/1335)) ([5117987](https://github.com/sct/overseerr/commit/5117987feaed21ccc19e64b04a15f2b77c22b880)) +- fall back to English genre names ([#1352](https://github.com/sct/overseerr/issues/1352)) ([e43106a](https://github.com/sct/overseerr/commit/e43106a434548840acecaf1276a5cebdc30e1345)) +- fix outofdate string & display version status badge in Settings > About ([#1417](https://github.com/sct/overseerr/issues/1417)) ([4eb9209](https://github.com/sct/overseerr/commit/4eb92098ba1f141bf74875ce76816a615763de5f)) +- various fixes for new tags feature ([#1369](https://github.com/sct/overseerr/issues/1369)) ([b4450a3](https://github.com/sct/overseerr/commit/b4450a308c56f767fbaa769d574a1b3f8e221d59)) +- **ui:** link request card status badge to Plex media URL ([#1361](https://github.com/sct/overseerr/issues/1361)) ([7a5c4a3](https://github.com/sct/overseerr/commit/7a5c4a30b5735fe6fbe821a8fcfdb4bcbeca68b3)) + +### Features + +- **lang:** Translations update from Weblate ([#1429](https://github.com/sct/overseerr/issues/1429)) ([a54241c](https://github.com/sct/overseerr/commit/a54241c775705fadc7c044f5312307f28f9a854b)) +- change alpha warning to beta warning ([03fd21b](https://github.com/sct/overseerr/commit/03fd21bebc3ffa34ce983b524d09e74b8ab2d057)) +- **lang:** translated using Weblate (Catalan) ([#1351](https://github.com/sct/overseerr/issues/1351)) ([35c13a8](https://github.com/sct/overseerr/commit/35c13a87467b4deabab3cb2cd1cab1b24ab51875)) +- **lang:** translations update from Weblate ([#1360](https://github.com/sct/overseerr/issues/1360)) ([8ee7693](https://github.com/sct/overseerr/commit/8ee7693a1f00a2f735b2555c7f8180c8a2c6144f)) +- **lang:** translations update from Weblate ([#1416](https://github.com/sct/overseerr/issues/1416)) ([dceca4d](https://github.com/sct/overseerr/commit/dceca4dd97f78f2e3aef678edcd5755c781f5249)) +- add overseerr version and update availability status to sidebar ([ecf1312](https://github.com/sct/overseerr/commit/ecf13123d21d765d67bfa7f9b6509b0f2af62cee)) +- **lang:** translations update from Weblate ([#1388](https://github.com/sct/overseerr/issues/1388)) ([9b199b2](https://github.com/sct/overseerr/commit/9b199b27d806e290cf0551e2d2ede6add61770aa)) +- **lang:** translations update from Weblate ([#1396](https://github.com/sct/overseerr/issues/1396)) ([3daf57e](https://github.com/sct/overseerr/commit/3daf57e9a12e4973dbc56656379ab2dbcb3c2619)) +- **notif:** allow users to enable/disable specific agents ([#1172](https://github.com/sct/overseerr/issues/1172)) ([46c4ee1](https://github.com/sct/overseerr/commit/46c4ee1625cf3e74bd885ecfc254b1e46cf44f29)) +- **webhook:** include requestedBy user in payload ([#1385](https://github.com/sct/overseerr/issues/1385)) ([e605687](https://github.com/sct/overseerr/commit/e60568758097d07f9d4b201ffdf34f0c32ba9cf3)) +- radarr/sonarr tag support ([#1366](https://github.com/sct/overseerr/issues/1366)) ([a306ebc](https://github.com/sct/overseerr/commit/a306ebc2d18317d8dbe4ccd3f24c22f55ffcd6a6)) + +# [1.22.0](https://github.com/sct/overseerr/compare/v1.21.1...v1.22.0) (2021-04-01) + +### Bug Fixes + +- **android:** adaptive icons for Android devices ([#1274](https://github.com/sct/overseerr/issues/1274)) ([a65e3d5](https://github.com/sct/overseerr/commit/a65e3d5bb6924cbde30b26ff8acf535e5274efee)) +- **backend:** fix getShowByTvdbId() error message ([#1314](https://github.com/sct/overseerr/issues/1314)) [skip ci] ([fe8d346](https://github.com/sct/overseerr/commit/fe8d34607b07095dce51b29ef7aaae0485573f14)) +- **db:** enable WAL journal mode ([aa205ff](https://github.com/sct/overseerr/commit/aa205ffa975d02ef0be30626e7c946a42679a847)) +- **frontend:** 'Recent Requests' slider should link to request list w/ same filter ([#1235](https://github.com/sct/overseerr/issues/1235)) ([49782c0](https://github.com/sct/overseerr/commit/49782c0b730cce9f0bad14e9c83842b5b0bfe11e)) +- **frontend:** call mutate after changing public settings ([#1302](https://github.com/sct/overseerr/issues/1302)) ([c8f67cf](https://github.com/sct/overseerr/commit/c8f67cf866ada791e4129a0bbae16b9eac41f32e)) +- **frontend:** include language parameter in TMDb links ([#1344](https://github.com/sct/overseerr/issues/1344)) ([1d88be9](https://github.com/sct/overseerr/commit/1d88be9341a8ff9e1f39b02556b489cdbd06392b)) +- **frontend:** redirect from /setup if already initialized ([#1238](https://github.com/sct/overseerr/issues/1238)) ([8016503](https://github.com/sct/overseerr/commit/80165038fd214897e3520a420f971341e7b94865)) +- **frontend:** use correct path to user profile in request modal quota dropdown ([#1307](https://github.com/sct/overseerr/issues/1307)) ([f990585](https://github.com/sct/overseerr/commit/f9905859148088afec53549b81611b07bf19d3b9)) +- **frontend:** use HTTPS to fetch TMDb assets for network/studio sliders ([#1343](https://github.com/sct/overseerr/issues/1343)) ([c886ea6](https://github.com/sct/overseerr/commit/c886ea6c0578cb7532d6c09266a76bfad8598b9d)) +- **frontend:** use next/image to serve login page images ([cbf4519](https://github.com/sct/overseerr/commit/cbf45196b023f60c8e4cf7602c0295f886fe610c)), closes [#1207](https://github.com/sct/overseerr/issues/1207) +- **lang:** allow proper localization of comma-delimited lists ([#1264](https://github.com/sct/overseerr/issues/1264)) ([173408a](https://github.com/sct/overseerr/commit/173408a1f269f09c724843ba087ef3f85b2832ad)) +- **lang:** change 'Extra Data' string to 'Additional Data' ([#1226](https://github.com/sct/overseerr/issues/1226)) ([665e164](https://github.com/sct/overseerr/commit/665e16475f3fa2ea6118340d9ea2d30b98abb238)) +- **lang:** correct mismatched language strings ([#1246](https://github.com/sct/overseerr/issues/1246)) ([8ebc829](https://github.com/sct/overseerr/commit/8ebc8292504cdc57a148ab69bcb4e1514ef018c6)) +- **lang:** correct strings for library sync button & user import toast ([#1252](https://github.com/sct/overseerr/issues/1252)) ([cb5ca7a](https://github.com/sct/overseerr/commit/cb5ca7acf38dcc2e27ec31d88434a11757cdb469)) +- **lang:** edit setting label strings for verb tense consistency ([#1214](https://github.com/sct/overseerr/issues/1214)) ([6d7671d](https://github.com/sct/overseerr/commit/6d7671dd80fea632e5cef29fc0b4968bffe231b0)) +- **lang:** fix overwritten/shared string ([#1212](https://github.com/sct/overseerr/issues/1212)) ([dfd4ff9](https://github.com/sct/overseerr/commit/dfd4ff9229822b0ce79ba322376194cbb6fd233d)) +- **lang:** remove 'requires and' ([#1215](https://github.com/sct/overseerr/issues/1215)) ([cb852fd](https://github.com/sct/overseerr/commit/cb852fded18f53806c23ec6f215385072b2a867b)) +- **lang:** remove unused strings ([#1330](https://github.com/sct/overseerr/issues/1330)) ([13e1595](https://github.com/sct/overseerr/commit/13e1595c6ebff32ca905d9bd3dd781e241545e83)) +- **lang:** UI string edits, round 2 ([#1202](https://github.com/sct/overseerr/issues/1202)) ([ea1863a](https://github.com/sct/overseerr/commit/ea1863ac3a5d3051e07815d07df0d3f2abd9166f)) +- **log:** fix typo in base scanner logging ([#1329](https://github.com/sct/overseerr/issues/1329)) [skip ci] ([b0b04ca](https://github.com/sct/overseerr/commit/b0b04ca1c7218ad5b67d9ec8b3fac5af78a4c132)) +- **logs:** add i18n strings for new log page changes ([8c51c28](https://github.com/sct/overseerr/commit/8c51c28f546b9c2d38ff7f20d59bb08a599e8146)) +- **notifications:** correctly send notifications for users that do not have any user settings yet ([d3a25b9](https://github.com/sct/overseerr/commit/d3a25b935aae35dd97ef0f168ac7e2898126a9a5)), closes [#1324](https://github.com/sct/overseerr/issues/1324) +- **overseerr-api.yml:** fixed pushbullet & webhook API definition refs and descriptions ([#1288](https://github.com/sct/overseerr/issues/1288)) [skip ci] ([3b003b7](https://github.com/sct/overseerr/commit/3b003b770120f7d150c64ff098b626015c030794)) +- **plex:** always send Overseerr for the device name to the plex.tv api ([f7146e4](https://github.com/sct/overseerr/commit/f7146e41899a59f75b963e1cc9dac9eddf24aebe)), closes [#1244](https://github.com/sct/overseerr/issues/1244) +- **ui:** add validation to hostname/IP fields ([#1206](https://github.com/sct/overseerr/issues/1206)) ([f49a024](https://github.com/sct/overseerr/commit/f49a02449c4928aef56cecbf908cf585ea0d4fca)) +- **ui:** better regex matching when parsing logs ([#1225](https://github.com/sct/overseerr/issues/1225)) ([2d737f2](https://github.com/sct/overseerr/commit/2d737f276095a8ca9abea360ef29134e9f639a39)) +- **ui:** button w/ dropdown z-indices ([#1230](https://github.com/sct/overseerr/issues/1230)) ([015671f](https://github.com/sct/overseerr/commit/015671f5be7a9f0f5c38db5a11a4b3c788dfaade)) +- **ui:** center role under title cards on person detail pages ([#1205](https://github.com/sct/overseerr/issues/1205)) ([4a61518](https://github.com/sct/overseerr/commit/4a6151873a3a3c5e45f9817131774a2c52957138)) +- **ui:** correctly enable the request button when partial requests are disabled with no quota ([16a611b](https://github.com/sct/overseerr/commit/16a611b9dfc3c66483640f4f5364646f41d37159)) +- **ui:** correctly paginate request list ([67fbb40](https://github.com/sct/overseerr/commit/67fbb401ac6ba05e58b8dfefd5954b28316254f2)) +- **ui:** correctly show quota display on tv request modal when only series quota is set ([3f1f85a](https://github.com/sct/overseerr/commit/3f1f85a80edfd2a4e9627162ff29ca6bcf2d8583)) +- **ui:** display asterisk indicator on required field labels ([#1236](https://github.com/sct/overseerr/issues/1236)) ([380d361](https://github.com/sct/overseerr/commit/380d36119f19a20ad67f79b3fb5db4036a093cac)) +- **ui:** do not check isValid on Sonarr/Radarr modals for the test button ([0974a4c](https://github.com/sct/overseerr/commit/0974a4c971358b7a64668f9a63fc356234a656c9)) +- **ui:** do not require numeric value in FormattedRelativeTime ([#1234](https://github.com/sct/overseerr/issues/1234)) ([3642b1e](https://github.com/sct/overseerr/commit/3642b1e84a20fef72428b3e240c86d35be8be8a2)) +- **ui:** filter out server options that do not match request type (non-4K or 4K) ([#1183](https://github.com/sct/overseerr/issues/1183)) ([28a6a70](https://github.com/sct/overseerr/commit/28a6a70e1ecc125f4cf4900e599ad0d4d7b55e3b)) +- **ui:** fix label formatting in general user settings ([#1275](https://github.com/sct/overseerr/issues/1275)) ([8546b0e](https://github.com/sct/overseerr/commit/8546b0ef53d232256b62cf08466e692a6971c16b)) +- **ui:** fix regex matching when parsing label from logs ([#1231](https://github.com/sct/overseerr/issues/1231)) ([4a00617](https://github.com/sct/overseerr/commit/4a00617fe47064ea50f95a02f29832a419ab13a3)) +- **ui:** gracefully handle lengthy titles & long words in overviews ([#1338](https://github.com/sct/overseerr/issues/1338)) ([d8bcb99](https://github.com/sct/overseerr/commit/d8bcb99b2fd3b24a5119ba5ff213a640425ff553)) +- **ui:** hide 'show details' button if there are no additional details ([#1254](https://github.com/sct/overseerr/issues/1254)) ([6210f12](https://github.com/sct/overseerr/commit/6210f12e8e9f593d629d22278d78310482ca0cfa)) +- **ui:** increase page size dropdown width when necessary ([#1216](https://github.com/sct/overseerr/issues/1216)) ([75c72b9](https://github.com/sct/overseerr/commit/75c72b987eb52b907ffd8af33f15ecc58213fc12)) +- **ui:** restore saved states of quota override checkboxes ([#1282](https://github.com/sct/overseerr/issues/1282)) ([2059fc1](https://github.com/sct/overseerr/commit/2059fc1cd4d48c7d80e761b7d41b7ec122d82769)) +- **ui:** sort regions & languages by their localized names rather than their TMDb English names ([#1157](https://github.com/sct/overseerr/issues/1157)) ([d76bf32](https://github.com/sct/overseerr/commit/d76bf32c9dcc83ebd0bae979726b1456a9028d8b)) +- **ui:** tweak request list design ([#1201](https://github.com/sct/overseerr/issues/1201)) ([d226fc7](https://github.com/sct/overseerr/commit/d226fc79b8d5f1263d4b80a7a1772074020ec94f)) +- **ui:** use appropriate cursor type for disabled UI elements ([#1184](https://github.com/sct/overseerr/issues/1184)) ([b767a58](https://github.com/sct/overseerr/commit/b767a58b011cc317a889cb8c2889b3210bec5fae)) +- **ui:** use appropriate cursor type for readonly input fields ([#1208](https://github.com/sct/overseerr/issues/1208)) ([9ec2c46](https://github.com/sct/overseerr/commit/9ec2c468cbbcbd41b94bbf9f3cfeb43eed09f36e)) +- **ui:** use correct colspan for 'No results.' message in Settings > Logs ([#1325](https://github.com/sct/overseerr/issues/1325)) ([5c135c9](https://github.com/sct/overseerr/commit/5c135c9974ebfcbdb434dafd459d1035624df6ed)) +- fetch localized person details from TMDb ([#1243](https://github.com/sct/overseerr/issues/1243)) ([1d7a938](https://github.com/sct/overseerr/commit/1d7a938ef8b0b8c20fda5024121de2a217ef4127)) + +### Features + +- **frontend:** add apple splash for pwa ([232def9](https://github.com/sct/overseerr/commit/232def972b9156afcbd83592708dbf8b5866ee24)) +- **frontend:** add apple tv+ to network slider ([3dc27ff](https://github.com/sct/overseerr/commit/3dc27ffd9bb054e6cda58872939dbc352877d184)), closes [#1219](https://github.com/sct/overseerr/issues/1219) +- **frontend:** allow selecting multiple original languages ([a908c07](https://github.com/sct/overseerr/commit/a908c07670532b0ca7f766065bb4653ce2376e6f)) +- **lang:** add Catalan to language picker ([#1309](https://github.com/sct/overseerr/issues/1309)) ([77911c0](https://github.com/sct/overseerr/commit/77911c03e98aa3c2c6c062a01c22b030704309c2)) +- **lang:** translations update from Weblate ([#1178](https://github.com/sct/overseerr/issues/1178)) ([3c89010](https://github.com/sct/overseerr/commit/3c89010629bc16f225f1d3936abe9f4e47a0d7c7)) +- **lang:** translations update from Weblate ([#1224](https://github.com/sct/overseerr/issues/1224)) ([c1975b3](https://github.com/sct/overseerr/commit/c1975b33f1115a95068be000b7f479a401f0f0ae)) +- **lang:** translations update from Weblate ([#1237](https://github.com/sct/overseerr/issues/1237)) ([dabd32a](https://github.com/sct/overseerr/commit/dabd32a18b42980059c7a7a7450514ca827a5d3b)) +- **lang:** translations update from Weblate ([#1256](https://github.com/sct/overseerr/issues/1256)) ([e9b1a9e](https://github.com/sct/overseerr/commit/e9b1a9e80e6b8285fa451a8551c5832a850c1746)) +- **lang:** translations update from Weblate ([#1281](https://github.com/sct/overseerr/issues/1281)) ([bec1d3d](https://github.com/sct/overseerr/commit/bec1d3dde834b9a50e24c5894c362e5982ff3bd5)) +- **lang:** translations update from Weblate ([#1305](https://github.com/sct/overseerr/issues/1305)) ([1b129c0](https://github.com/sct/overseerr/commit/1b129c0b3863ea3c5ad34c66b3ace5d09cd4e391)) +- **lang:** translations update from Weblate ([#1313](https://github.com/sct/overseerr/issues/1313)) ([18ce349](https://github.com/sct/overseerr/commit/18ce349faac6ee560b9c92374039954f2365a8d1)) +- **logs:** add copy to clipboard button to logs page ([e2b8745](https://github.com/sct/overseerr/commit/e2b8745fdc192f3d49872625652184005a760885)) +- **notif:** include requested season numbers in notifications ([#1211](https://github.com/sct/overseerr/issues/1211)) ([4ee78ab](https://github.com/sct/overseerr/commit/4ee78ab2fe0359df6baa58f0986687f05a8392a2)) +- **requests:** add request quotas ([#1277](https://github.com/sct/overseerr/issues/1277)) ([6c75c88](https://github.com/sct/overseerr/commit/6c75c8822842514ffd31864992e8d3ce686fea1b)) +- **settings:** logs viewer ([#997](https://github.com/sct/overseerr/issues/997)) ([54429bb](https://github.com/sct/overseerr/commit/54429bbc1d765d0e50486a42749f9bbd4e5b3386)) +- **ui:** add movie/series genre list pages ([#1194](https://github.com/sct/overseerr/issues/1194)) ([6f1a31d](https://github.com/sct/overseerr/commit/6f1a31de473d1a25bc77e0961a52b07050b64c51)) +- **ui:** add option to only allow complete series requests ([#1164](https://github.com/sct/overseerr/issues/1164)) ([36c00fd](https://github.com/sct/overseerr/commit/36c00fde273799a56ec42ce6177ff44fed0904c3)) +- **ui:** Add user requests page ([#936](https://github.com/sct/overseerr/issues/936)) ([a9461f7](https://github.com/sct/overseerr/commit/a9461f760d8112f2ae16183e796f706d3392f8ec)) +- **ui:** allow any value 1-100 for quota limit/days ([#1337](https://github.com/sct/overseerr/issues/1337)) ([f4bed9a](https://github.com/sct/overseerr/commit/f4bed9a63b6b856ebedca9eb7662cd00038d7f7c)) +- **ui:** display movie/series original title ([#1240](https://github.com/sct/overseerr/issues/1240)) ([7230915](https://github.com/sct/overseerr/commit/723091509414465e98d870b3dc943f41b9ac590d)) +- **ui:** experimental status bar style change for ios pwa app ([958cdf9](https://github.com/sct/overseerr/commit/958cdf98fd1cb7c1bdb33aebb6c061750e9ab331)) +- **ui:** store sort order and page size of userlist in localstorage ([#1262](https://github.com/sct/overseerr/issues/1262)) ([f5f8269](https://github.com/sct/overseerr/commit/f5f8269cd28ee792120060f4f38ef09d571fb8d5)) +- add option to cache images locally ([#1213](https://github.com/sct/overseerr/issues/1213)) ([0ca3d43](https://github.com/sct/overseerr/commit/0ca3d4374942b54b59a19d017ab4ae14ba7019c1)) +- genre sliders (experiment) ([#1182](https://github.com/sct/overseerr/issues/1182)) ([1c4515a](https://github.com/sct/overseerr/commit/1c4515a1ae6097f3948aaa0d0ed210831581fd98)) + +### Reverts + +- **ui:** remove local image cache option from settings page ([911faef](https://github.com/sct/overseerr/commit/911faeff562b737a2d18a395fcd90bf354af0cc4)) +- remove experimental tailwind jit compiler until title card hover is fixed ([1df67ba](https://github.com/sct/overseerr/commit/1df67baf9e7cdabc4045a0c115735797e8081bca)) +- **deps:** revert react-intl to 5.13.5 ([e16277c](https://github.com/sct/overseerr/commit/e16277c07d58ddbb749f4a60bc05924f4a5af146)) + +## [1.21.1](https://github.com/sct/overseerr/compare/v1.21.0...v1.21.1) (2021-03-15) + +### Bug Fixes + +- **lang:** translations update from Weblate ([#1155](https://github.com/sct/overseerr/issues/1155)) ([ebc285c](https://github.com/sct/overseerr/commit/ebc285c758f69846e4a5cb74bb42ca5924d166d4)) + +# [1.21.0](https://github.com/sct/overseerr/compare/v1.20.1...v1.21.0) (2021-03-15) + +### Bug Fixes + +- do not allow editing of user settings under certain conditions ([#1168](https://github.com/sct/overseerr/issues/1168)) ([001dcd3](https://github.com/sct/overseerr/commit/001dcd328c8d3b1c417fd7c7ee2aa20183b08eef)) +- **frontend:** check for ID instead of email after initial setup Plex login ([#1097](https://github.com/sct/overseerr/issues/1097)) ([778dda6](https://github.com/sct/overseerr/commit/778dda67d54df87347dd79577ef1bdc88d3c1d3f)) +- **frontend:** check if swr is validating to determine if we should fetch new data ([e5f5bdb](https://github.com/sct/overseerr/commit/e5f5bdb95c62eba31a3321a7457d354f0226bf85)), closes [#719](https://github.com/sct/overseerr/issues/719) +- **frontend:** never hide available content in search results ([d48edeb](https://github.com/sct/overseerr/commit/d48edeb5a9bd8e2edce8bca0fea50e300bb7a1ae)) +- **lang:** add missing i18n strings ([6072e8a](https://github.com/sct/overseerr/commit/6072e8aa9a0f84e50c44a92af303aad15b5f3021)) +- **lang:** edit new Telegram-related strings to conform to style guide ([#1093](https://github.com/sct/overseerr/issues/1093)) ([bdf67e7](https://github.com/sct/overseerr/commit/bdf67e732b6c77cbae768a25edfc9a663ef0108b)) +- **notif:** loosen input validation on Pushover settings ([#1166](https://github.com/sct/overseerr/issues/1166)) ([3148d31](https://github.com/sct/overseerr/commit/3148d312141248653c5d1e42cd2882a67a339163)) +- **notif:** set URL for Discord embeds rather than adding a field for the link ([#1167](https://github.com/sct/overseerr/issues/1167)) ([0bd0912](https://github.com/sct/overseerr/commit/0bd0912613f0db24bd0da4ec956b5119133e35d4)) +- correctly send auto-approval notifictions for series ([8634081](https://github.com/sct/overseerr/commit/8634081c869a2078793ecf06b1b7e249bba0a2f8)) +- **lang:** fix singular form of season count ([#1080](https://github.com/sct/overseerr/issues/1080)) ([b57645d](https://github.com/sct/overseerr/commit/b57645d382361c856281e7a74295afe16c5390f2)) +- **requests:** add plex url to request item ([#1088](https://github.com/sct/overseerr/issues/1088)) ([420038d](https://github.com/sct/overseerr/commit/420038d5ffdd4070df03e5c5cb6ef8d6208fddb5)) +- **sonarr:** correctly search when updating existing sonarr series ([ed0a7fb](https://github.com/sct/overseerr/commit/ed0a7fbdf5122a26fa936e83b76a97c55781782d)), closes [#588](https://github.com/sct/overseerr/issues/588) +- **ui:** add alt prop to studio/network logos & fix blinking text cursor ([#1095](https://github.com/sct/overseerr/issues/1095)) ([0c4637f](https://github.com/sct/overseerr/commit/0c4637f779d8904037b9cbd5fe9166cf05a891c5)) +- **ui:** add link to poster image on request items ([7289872](https://github.com/sct/overseerr/commit/7289872937d5bb94d027424760ee1ceb94095604)) +- **ui:** correct language usage re: "sync" vs. "scan" ([#1079](https://github.com/sct/overseerr/issues/1079)) ([e98f2b9](https://github.com/sct/overseerr/commit/e98f2b96058fb9c5af77be2e8a1bd07fb8fcca06)) +- **ui:** display "Season" vs. "Seasons" as appropriate, and fix request block "Seasons" formatting ([#1127](https://github.com/sct/overseerr/issues/1127)) ([45886cc](https://github.com/sct/overseerr/commit/45886ccef1bee57dc555060a491834567e45b59c)) +- **ui:** request list button sizes ([#1152](https://github.com/sct/overseerr/issues/1152)) ([fc73592](https://github.com/sct/overseerr/commit/fc73592b69c38191f91a68a020868b8e5ec2e2e2)) +- fix language filter link on movie detail pages ([#1142](https://github.com/sct/overseerr/issues/1142)) ([60d453b](https://github.com/sct/overseerr/commit/60d453b0bbba5e2060f72f40d1dde85ec6b05af4)) +- remove language/region filtering on studio/network results ([#1129](https://github.com/sct/overseerr/issues/1129)) ([109aca8](https://github.com/sct/overseerr/commit/109aca8229dc7b81cac314d84591f1c04c12ac2e)) +- **api:** check correct permissions for auto approve when requests are created ([3c1a72b](https://github.com/sct/overseerr/commit/3c1a72b038fd178b4be4dc082cd1496474148d7e)) +- **frontend:** status, requested by, and modified alignment fix ([#1109](https://github.com/sct/overseerr/issues/1109)) ([1a7dc1a](https://github.com/sct/overseerr/commit/1a7dc1acf57888d3d0285b58c1c97a824a232216)) +- **ui:** don't show "Password" user settings tab if current user lacks perms to modify the password ([#1063](https://github.com/sct/overseerr/issues/1063)) ([b146d11](https://github.com/sct/overseerr/commit/b146d11e2ffecedae76472b0491a4662ca4a4a4e)) +- **ui:** fix Radarr logo alignment ([#1068](https://github.com/sct/overseerr/issues/1068)) ([0fa005a](https://github.com/sct/overseerr/commit/0fa005a99cd868b5a235ae9ce65b4c64b05d0f47)) +- **ui:** fix request list UI behavior when season list is too long ([#1106](https://github.com/sct/overseerr/issues/1106)) ([8507691](https://github.com/sct/overseerr/commit/85076919c6ccbf052699b7d5f4ba8b6e5e5af74d)) +- **ui:** improve responsive design on new request list UI ([#1105](https://github.com/sct/overseerr/issues/1105)) ([1f8b03f](https://github.com/sct/overseerr/commit/1f8b03ff6f67ce76051667de05166da54ed3dc89)) +- **ui:** list all movie studios instead of just the first result ([#1110](https://github.com/sct/overseerr/issues/1110)) ([239202d](https://github.com/sct/overseerr/commit/239202d9c11f27410b0fa084bcc4c824b7136081)) +- add correct permission checks to modifying user password/permissions ([ddfc5e6](https://github.com/sct/overseerr/commit/ddfc5e6aa8fc636931f495d6f23d56367466e3b5)) + +### Features + +- add tagline, episode runtime, genres list to media details & clean/refactor CSS into globals ([#1160](https://github.com/sct/overseerr/issues/1160)) ([2f2e002](https://github.com/sct/overseerr/commit/2f2e00237d43bdab85bfadc3c4f2fbcdde4c2e90)) +- **docker:** add tini to docker image ([#1017](https://github.com/sct/overseerr/issues/1017)) ([1629d02](https://github.com/sct/overseerr/commit/1629d02f3d8368bfd5f6fed05382974ae6fce51f)) +- **email:** add pgp support ([#1138](https://github.com/sct/overseerr/issues/1138)) ([9e5adeb](https://github.com/sct/overseerr/commit/9e5adeb610bdc4800ff536412d0ae8a11fb4338d)) +- **frontend:** add loading bar indicator ([#1170](https://github.com/sct/overseerr/issues/1170)) ([3d6b343](https://github.com/sct/overseerr/commit/3d6b3434138fec49c58f2bf74f781d5e2fc2911f)) +- **lang:** localize job names ([#1043](https://github.com/sct/overseerr/issues/1043)) ([594aad9](https://github.com/sct/overseerr/commit/594aad9d3ae9b323677f3af8c434d7664526593d)) +- **lang:** translations update from Weblate ([#1051](https://github.com/sct/overseerr/issues/1051)) ([69bf817](https://github.com/sct/overseerr/commit/69bf817f598babed99964f073259f827b60bd014)) +- **lang:** Translations update from Weblate ([#1131](https://github.com/sct/overseerr/issues/1131)) ([e4686d6](https://github.com/sct/overseerr/commit/e4686d664b52448e32488ff1c4236f72e01e9a29)) +- **notif:** add "Media Automatically Approved" notification type ([#1137](https://github.com/sct/overseerr/issues/1137)) ([f7d2723](https://github.com/sct/overseerr/commit/f7d2723fab2c30564fd23945709cd39b178a6eef)) +- **notif:** add settings for Discord bot username & avatar URL ([#1113](https://github.com/sct/overseerr/issues/1113)) ([3384eb1](https://github.com/sct/overseerr/commit/3384eb1c479114c0246cb22f9a933aa79fb95fcf)) +- **notif:** include poster image in Telegram notifications ([#1112](https://github.com/sct/overseerr/issues/1112)) ([48387e5](https://github.com/sct/overseerr/commit/48387e5b2f26c0c33acd436c6e1cf902d6c32101)) +- **scan:** add support for new plex tv agent ([#1144](https://github.com/sct/overseerr/issues/1144)) ([a51d2a2](https://github.com/sct/overseerr/commit/a51d2a24d51d092a0c6da608e3322f19a37c2d28)) +- **ui:** add user ID to profile header ([6e95c8b](https://github.com/sct/overseerr/commit/6e95c8b7a10e3467bfd2c3df84ccf886fe01ca5c)) +- add genre/studio/network view to Discover results ([#1067](https://github.com/sct/overseerr/issues/1067)) ([f28112f](https://github.com/sct/overseerr/commit/f28112f057df2589f31ae0d0b14e8b50e479fdb7)) +- add language-filtered Discover pages ([#1111](https://github.com/sct/overseerr/issues/1111)) ([7501161](https://github.com/sct/overseerr/commit/75011610e57f03098c8be9375d0c9ba1e3647e9b)) +- add studio/network sliders to discover ([1c6914f](https://github.com/sct/overseerr/commit/1c6914f5ce5c0d171c4609813915b50233a8e3ad)) +- **telegram:** add support for individual chat notifications ([#1027](https://github.com/sct/overseerr/issues/1027)) ([f6d00d8](https://github.com/sct/overseerr/commit/f6d00d8d1559879189f83739193c6e2acafde51d)) +- **ui:** display "Owner" role instead of "Admin" for user ID 1 ([#1050](https://github.com/sct/overseerr/issues/1050)) ([1b55d2d](https://github.com/sct/overseerr/commit/1b55d2dfbc06d900e7370a4ddfd81789a25bf00c)) +- **ui:** display season count on TV details page ([#1078](https://github.com/sct/overseerr/issues/1078)) ([4365231](https://github.com/sct/overseerr/commit/436523139e8f1594c352b17032734b4498d3994f)) +- **ui:** in Settings > Services, make Radarr/Sonarr server names and logos clickable links ([#1008](https://github.com/sct/overseerr/issues/1008)) ([6a1e389](https://github.com/sct/overseerr/commit/6a1e3891aa5f84b6adb1e475a6658a8cd4e34c22)) +- **ui:** request list redesign ([#1099](https://github.com/sct/overseerr/issues/1099)) ([cd21865](https://github.com/sct/overseerr/commit/cd21865c4d5be00c13c372e0b7a058f61ec855a2)) + +## [1.20.1](https://github.com/sct/overseerr/compare/v1.20.0...v1.20.1) (2021-02-28) + +### Bug Fixes + +- **notif:** escape application title in Telegram notifications ([#1012](https://github.com/sct/overseerr/issues/1012)) ([5560abf](https://github.com/sct/overseerr/commit/5560abf459b0350ff30b5e71d4208418fc8f3b3e)) +- **notif:** fixed typo in pushover hint ([#1029](https://github.com/sct/overseerr/issues/1029)) ([e9f2fe9](https://github.com/sct/overseerr/commit/e9f2fe910d72fa41bc27673ed43291211c3cac65)) +- **notifications:** correctly send tv auto approval notifications ([537850f](https://github.com/sct/overseerr/commit/537850f414a88df24c78794a2fd68e1e24ff73d1)), closes [#1041](https://github.com/sct/overseerr/issues/1041) +- **plex-sync:** no longer incorrectly sets 4k availability when there isnt any ([3f9a116](https://github.com/sct/overseerr/commit/3f9a116b17d78eeb04f0f125a4f3af6f907c83dd)), closes [#990](https://github.com/sct/overseerr/issues/990) +- **ui:** for server default options, display "All" region/language option instead of empty string ([#1042](https://github.com/sct/overseerr/issues/1042)) ([3fed26c](https://github.com/sct/overseerr/commit/3fed26cfbe74cb662ca531fd37b69f159a051ac1)) +- **ui:** show translated string on sonarr sucesss/failure toast messages ([#1035](https://github.com/sct/overseerr/issues/1035)) ([eefcbcd](https://github.com/sct/overseerr/commit/eefcbcd3ddfa5258ee24dbbbd79de5bf50310f27)) +- **ui:** use country-flag-icons instead of country-flag-emoji for RegionSelector ([#1011](https://github.com/sct/overseerr/issues/1011)) ([abcd7c9](https://github.com/sct/overseerr/commit/abcd7c997584c1310bd8b313ac38f30e335af8d7)) +- add missing default value for settings context ([084917f](https://github.com/sct/overseerr/commit/084917f02d399e2d29bb9927e033c2e6533f586c)) +- added missing language default for ssr context defaults ([9ce88ab](https://github.com/sct/overseerr/commit/9ce88abcc85d744d77172cd2357fdb4ff60dc5e4)) +- allow users to override language/region settings ([69294a7](https://github.com/sct/overseerr/commit/69294a7c4c5bbe55c5cd276786cdfd48ddbff889)), closes [#1013](https://github.com/sct/overseerr/issues/1013) + +# [1.20.0](https://github.com/sct/overseerr/compare/v1.19.1...v1.20.0) (2021-02-23) + +### Bug Fixes + +- **api:** add isAuthenticated middleware to base user route ([8a27c70](https://github.com/sct/overseerr/commit/8a27c7062599ea23dca115e6e6e95a594e1b219a)) +- **api:** sort users requests by most recent ([1798383](https://github.com/sct/overseerr/commit/17983837fc10661a59d29fc1531530fca0d77825)) +- **api:** Use POST instead of GET for API endpoints that mutate state ([#877](https://github.com/sct/overseerr/issues/877)) ([ff0b5ed](https://github.com/sct/overseerr/commit/ff0b5ed44132cc5a0cd178035796d042ba735a8d)) +- **auth:** handle sign-in attempts from emails with no password ([#933](https://github.com/sct/overseerr/issues/933)) ([5e37a96](https://github.com/sct/overseerr/commit/5e37a96bc017471f8dc4cbdd57f2e8c3568bd97f)) +- **frontend:** changed plex, request, and cog buttons to align properly on smaller mobile UIs ([#928](https://github.com/sct/overseerr/issues/928)) ([f1c3358](https://github.com/sct/overseerr/commit/f1c335815f2f17465cdd36ceb223e78a58149b3b)) +- **frontend:** check for id instead of email after logging in ([c4af4c4](https://github.com/sct/overseerr/commit/c4af4c42ab00f1a63a2f5326c9cd8b26c19f4f14)) +- **frontend:** Do not allow user w/ ID 1 to disable 'Admin' permission ([#965](https://github.com/sct/overseerr/issues/965)) ([77b2d9e](https://github.com/sct/overseerr/commit/77b2d9ea22a2f70cff58ac9421f3f6231bc93059)) +- **frontend:** handle empty array of media attributes ([#922](https://github.com/sct/overseerr/issues/922)) ([04fa9f7](https://github.com/sct/overseerr/commit/04fa9f79e2ec90082b3fa15590dd170f7d68ad52)) +- **frontend:** request and cog button would be misaligned without play on plex/watch trailer button ([#956](https://github.com/sct/overseerr/issues/956)) ([e28dfad](https://github.com/sct/overseerr/commit/e28dfadaf57d47887013c31dc5006332473156e3)) +- **frontend:** Update AdvancedRequester to reflect new /user API response ([#970](https://github.com/sct/overseerr/issues/970)) ([b4bac6a](https://github.com/sct/overseerr/commit/b4bac6a9157119a4f234933245944e133c127bd0)) +- **frontend:** use region settings instead of hardcoded 'US' value for movie/TV ratings ([#1006](https://github.com/sct/overseerr/issues/1006)) ([6ecd202](https://github.com/sct/overseerr/commit/6ecd202607cb48d559440da810ecc585e740542b)) +- **lang:** formatMessage should not use an object spread ([8a7fa00](https://github.com/sct/overseerr/commit/8a7fa00164fd5c5501da525baa29be97bac7e7c4)) +- **lang:** Remove unused strings and correct spelling of 'canceling'/'canceled' ([#981](https://github.com/sct/overseerr/issues/981)) ([5b64655](https://github.com/sct/overseerr/commit/5b646557765d1ad75e44e1c0e60e0291313c7746)) +- **login:** fix the gap when 'use your overseer account' was selected ([#870](https://github.com/sct/overseerr/issues/870)) ([d163e29](https://github.com/sct/overseerr/commit/d163e294599c4bd9bdc0a148db15c8e8541410d8)) +- **notif:** Do not HTML-escape email subjects ([#931](https://github.com/sct/overseerr/issues/931)) ([019622a](https://github.com/sct/overseerr/commit/019622aab1b94cc4d71cacbf0dc5cf64b62c8623)) +- **notif:** Remove extra newlines from Telegram notifications ([#973](https://github.com/sct/overseerr/issues/973)) ([bbea522](https://github.com/sct/overseerr/commit/bbea52249950eb98a8d3886f2bd7648a7d669bf4)) +- **plex:** Check Plex server access on user import ([#955](https://github.com/sct/overseerr/issues/955)) ([bdb3cb2](https://github.com/sct/overseerr/commit/bdb3cb202550e34d8951ac2b5015f97f6a5c1ebf)) +- **plex-sync:** get correct Plex metadata for Hama movie items ([#901](https://github.com/sct/overseerr/issues/901)) ([03cecb3](https://github.com/sct/overseerr/commit/03cecb33559e27199c5a174fc86de0c4550fe666)), closes [#898](https://github.com/sct/overseerr/issues/898) +- **requests:** correctly filter requests out for users without view requests permission ([e118501](https://github.com/sct/overseerr/commit/e118501bf1dfa8dada2c57090e62631de620f3dd)) +- **requests:** correctly handle when tvdbid is missing ([#891](https://github.com/sct/overseerr/issues/891)) ([e037ba4](https://github.com/sct/overseerr/commit/e037ba48f173c06b0c9c8b03085edf832d770c06)) +- **search:** Handle search errors and escape \* ([#893](https://github.com/sct/overseerr/issues/893)) ([034968e](https://github.com/sct/overseerr/commit/034968e4370eaea726c94730274349c083856813)) +- **services:** update all radarr/sonarr endpoints to use v3 ([da5ca02](https://github.com/sct/overseerr/commit/da5ca02f81fe91070afbda3e1ebc8d869fe39a8f)) +- **sonarr:** use qualityProfileId instad of profileId when adding series ([552a7e3](https://github.com/sct/overseerr/commit/552a7e30da5fc2cc0bd43b5aef79a0225c75d233)) +- **sync:** fix sonarr/plex sync fighting over availability ([9b73423](https://github.com/sct/overseerr/commit/9b73423d49e1e799cd82764a9ade8c75d92a28a2)), closes [#872](https://github.com/sct/overseerr/issues/872) +- **ui:** add fallback for region display name ([f9c83e1](https://github.com/sct/overseerr/commit/f9c83e14e52a57d6865307b3324a61c04a77a541)) +- **ui:** add missing string for default Discover Language & edit string for default Discover Region ([#1004](https://github.com/sct/overseerr/issues/1004)) ([0acad8e](https://github.com/sct/overseerr/commit/0acad8e9fa65a9de6cecac9b6a4a5b2313ba8f06)) +- **ui:** Add tip & validation for Discord ID input ([#966](https://github.com/sct/overseerr/issues/966)) ([e70a4ec](https://github.com/sct/overseerr/commit/e70a4ecae613e045977e262fd7f9643f30985ab7)) +- **ui:** also allow 17 digit discord ids ([57c00c1](https://github.com/sct/overseerr/commit/57c00c1ea71c1229d5a59e1b8dadd84a646772b9)), closes [#971](https://github.com/sct/overseerr/issues/971) +- **ui:** Automatically disable and uncheck user permissions with unmet requirements ([#941](https://github.com/sct/overseerr/issues/941)) ([c9a150b](https://github.com/sct/overseerr/commit/c9a150b1db2adbb305cf1a448489d7a8c14cf1cb)) +- **ui:** change font size in request list/user list dropdowns to prevent zoom on mobile ([fb9c878](https://github.com/sct/overseerr/commit/fb9c878db49c01d13773e8d2f94c93f840be0b82)) +- **ui:** Display 4K download status on 4K status badge ([#988](https://github.com/sct/overseerr/issues/988)) ([40b07c3](https://github.com/sct/overseerr/commit/40b07c35d40c03039e4bfa5ed1e73af7e8aa6a7d)) +- **ui:** Fix card sizes on person detail pages ([#881](https://github.com/sct/overseerr/issues/881)) ([a3042f8](https://github.com/sct/overseerr/commit/a3042f8e1b05a91d98f48a4aecb08e831a48fc56)) +- **ui:** Fix settings navigation horizontal scroll issues ([#987](https://github.com/sct/overseerr/issues/987)) ([8701fb2](https://github.com/sct/overseerr/commit/8701fb20d07773f4cc32e857b68575a813cf7e21)) +- **ui:** fix webhook URL validation regex ([baad19a](https://github.com/sct/overseerr/commit/baad19a2c94728313ee996fe1a0ffc64fbd9aaa3)) +- **ui:** fixed anime language profile typo ([#879](https://github.com/sct/overseerr/issues/879)) ([ee50761](https://github.com/sct/overseerr/commit/ee5076146ef3c5e8baba197a5b397d3c3f575262)) +- **ui:** Handle missing movie/series data ([#862](https://github.com/sct/overseerr/issues/862)) ([7c0ddad](https://github.com/sct/overseerr/commit/7c0ddad653393327226a877692f046d8693ddc66)) +- **ui:** Notification-related string/UI edits and field validation ([#985](https://github.com/sct/overseerr/issues/985)) ([c88fcb2](https://github.com/sct/overseerr/commit/c88fcb2e2d1c4b84527844a80680c15337626e72)) +- **ui:** rename global group class to form-group ([8056187](https://github.com/sct/overseerr/commit/8056187c3c0ea464a8f751aa6347ea1d35c01aac)) +- **ui:** Size cards appropriately based on base font size ([#871](https://github.com/sct/overseerr/issues/871)) ([282f28f](https://github.com/sct/overseerr/commit/282f28f2b9d0cc8c9105d01b43d4e1f730320b8b)) +- **ui/notif:** Custom application title in password-related emails and UI messages ([#979](https://github.com/sct/overseerr/issues/979)) ([4e2706b](https://github.com/sct/overseerr/commit/4e2706b4211b06f364910c327d84c2ceb45b2fe3)) + +### Features + +- **lang:** translated using Weblate (French) ([#1007](https://github.com/sct/overseerr/issues/1007)) ([970da66](https://github.com/sct/overseerr/commit/970da664b2700b8cd9ad8dce0cbca1d37820eceb)) +- **lang:** translations update from Weblate ([#853](https://github.com/sct/overseerr/issues/853)) ([e156acc](https://github.com/sct/overseerr/commit/e156acc1ae2fa86b4441faacc0b58e1e993e0edc)) +- **lang:** translations update from Weblate ([#986](https://github.com/sct/overseerr/issues/986)) ([4296765](https://github.com/sct/overseerr/commit/4296765ad61bac09c2317b71b763366d328733e4)) +- **notif:** Add Pushbullet notification agent ([#950](https://github.com/sct/overseerr/issues/950)) ([29b97ef](https://github.com/sct/overseerr/commit/29b97ef6d85bbea31dd59b7ad857b0d8ab30bff0)) +- **notif:** Notification improvements ([#914](https://github.com/sct/overseerr/issues/914)) ([2768155](https://github.com/sct/overseerr/commit/2768155bbabe121a4c51fc1472461cd5114c4300)) +- **regions:** add region/original language setting for filtering Discover ([#732](https://github.com/sct/overseerr/issues/732)) ([#942](https://github.com/sct/overseerr/issues/942)) ([b557c06](https://github.com/sct/overseerr/commit/b557c06b0a78f5df5f64a05dc1e4511dae72df4f)) +- **requests:** add language profile support ([#860](https://github.com/sct/overseerr/issues/860)) ([53f6f59](https://github.com/sct/overseerr/commit/53f6f59798fa7e3f95959990a3df555db3c1c51e)) +- **ui:** Add 'Available' filter to request list and remove unused MediaRequestStatus.AVAILABLE enum value ([#905](https://github.com/sct/overseerr/issues/905)) ([9757e3a](https://github.com/sct/overseerr/commit/9757e3ae0c572fb46177e25154b29e0ceced665f)) +- **ui:** Add 'Page Size' setting for request/user list pages ([#957](https://github.com/sct/overseerr/issues/957)) ([621db89](https://github.com/sct/overseerr/commit/621db893281f0280fe773ac7dbdc44434895242c)) +- **ui:** Add separate permissions for 4K auto approval ([#908](https://github.com/sct/overseerr/issues/908)) ([53b7425](https://github.com/sct/overseerr/commit/53b7425f6711e250935e7bb024c38ff6c62e07d9)) +- **ui:** Add sort options to user list ([#913](https://github.com/sct/overseerr/issues/913)) ([ef5d019](https://github.com/sct/overseerr/commit/ef5d019c18d7f6cdbbb1e1b7f8ff7816ed9b117b)) +- **ui:** Add support for requesting collections in 4K ([#968](https://github.com/sct/overseerr/issues/968)) ([139341b](https://github.com/sct/overseerr/commit/139341b0434b41e7c31af36baacd8d65566a6a0c)) +- user profile/settings pages ([#958](https://github.com/sct/overseerr/issues/958)) ([bbb683e](https://github.com/sct/overseerr/commit/bbb683e637386ad8bbeb44dca97aac9cdaf11349)) +- **ui:** added content ratings for tv shows and movie ratings ([#878](https://github.com/sct/overseerr/issues/878)) ([c8b2a57](https://github.com/sct/overseerr/commit/c8b2a57721a51adcc7f90ec1acb48b127991d467)) +- **users:** add reset password flow ([#772](https://github.com/sct/overseerr/issues/772)) ([e5966bd](https://github.com/sct/overseerr/commit/e5966bd3fbfe172f264f4e986ad2aecf29ae1510)) + +## [1.19.1](https://github.com/sct/overseerr/compare/v1.19.0...v1.19.1) (2021-02-06) + +### Bug Fixes + +- **ui:** Fix webhook URL validation regex ([#864](https://github.com/sct/overseerr/issues/864)) ([726f62b](https://github.com/sct/overseerr/commit/726f62b9b69b5078e718f129e26abdf358f5cb06)) + +# [1.19.0](https://github.com/sct/overseerr/compare/v1.18.0...v1.19.0) (2021-02-05) + +### Bug Fixes + +- **api:** filter out adult content from combined credits ([3052f12](https://github.com/sct/overseerr/commit/3052f12c91b3ce86128324e3698fff61bbce3f2a)) +- **cache:** use formatted numbers for displaying cache counts ([6c437c5](https://github.com/sct/overseerr/commit/6c437c515fc01b9fe4461968875e23542bae7542)) +- **email:** make image a link to the action url in request template ([ee0a7bd](https://github.com/sct/overseerr/commit/ee0a7bd8c0b3a79c292b0abceb2f780f3889e49f)), closes [#834](https://github.com/sct/overseerr/issues/834) +- **frontend:** add github sponsor link to about page ([7c192d5](https://github.com/sct/overseerr/commit/7c192d54f422a5f2b55750535d2382e313f1d011)) +- **frontend:** correctly show 4k download tracker activity ([a7314f8](https://github.com/sct/overseerr/commit/a7314f876ea528fdec0fb0a2adaa36a01afcdf38)) +- **frontend:** fix possible division by zero in download status ([#839](https://github.com/sct/overseerr/issues/839)) ([c97c96a](https://github.com/sct/overseerr/commit/c97c96a30c50db7735f06c6d2d2f6193fb7da55e)) +- **frontend:** match request button color on titlecards to other request buttons ([5b39911](https://github.com/sct/overseerr/commit/5b39911e024513fab7a62948e653cee08fd166c7)) +- **frontend:** set 4k status on RequestItem when request is for 4k ([a3b00c3](https://github.com/sct/overseerr/commit/a3b00c3458b868506d4158fb24f0369fa5daefc5)) +- **frontend:** use consistent spinner style on TitleCard/Plex Presets ([cf7ebc4](https://github.com/sct/overseerr/commit/cf7ebc488db33725444c428b4244d780ab9d123b)) +- **html:** th elements should be nested under tr, not directly under thead ([#801](https://github.com/sct/overseerr/issues/801)) ([6e9ac27](https://github.com/sct/overseerr/commit/6e9ac275e19d56de8c7a366db970c7321f26fc8a)) +- **lang:** Add missing source strings & remove local user sign-in setting tip ([#828](https://github.com/sct/overseerr/issues/828)) ([c0769d4](https://github.com/sct/overseerr/commit/c0769d4f8f2bad88e4638d8c3cbcc0414b3ef6fb)) +- **lang:** Edit English language strings ([#820](https://github.com/sct/overseerr/issues/820)) ([f54df21](https://github.com/sct/overseerr/commit/f54df214af86d90ea8d7cfcd4e39022215c3568c)) +- **lang:** translate language names & change zh-Hant language code to zh-TW ([#793](https://github.com/sct/overseerr/issues/793)) ([3c5ae36](https://github.com/sct/overseerr/commit/3c5ae360fd179d794a78cc918fe97a09216ca6b2)) +- **notif/ui:** Use custom application title in notifications & sign-in page ([#849](https://github.com/sct/overseerr/issues/849)) ([38c76b5](https://github.com/sct/overseerr/commit/38c76b55e0039c489cb6a4a0a298aa6385406db4)) +- **radarr:** correctly set requested status after sending to radarr (with auto approve) ([ec44841](https://github.com/sct/overseerr/commit/ec448413569ddc2f24bb856d29084169979f9f05)) +- **sonarr-sync:** sonarr sync will no longer set shows with no episodes to partially available ([d20bd53](https://github.com/sct/overseerr/commit/d20bd530edaadc5887b0361358da80153e36505c)), closes [#796](https://github.com/sct/overseerr/issues/796) +- **ui:** Add additional URL & email input validation ([#843](https://github.com/sct/overseerr/issues/843)) ([3f9bfeb](https://github.com/sct/overseerr/commit/3f9bfeb01a67b2b587c7548b02ee826722e65c0f)) +- **ui:** Don't display empty dropdown when no trailer available ([#804](https://github.com/sct/overseerr/issues/804)) ([95c2a21](https://github.com/sct/overseerr/commit/95c2a2169799d96413b47ab24506b330435643eb)) +- **ui:** dont show bulk edit options on user list if there is only one user ([b658ddf](https://github.com/sct/overseerr/commit/b658ddf5cf61b2bb9b93cb1a4ca716cd75e18bb4)) +- **ui:** Dynamically generate path to config in warning message ([#851](https://github.com/sct/overseerr/issues/851)) ([b531a64](https://github.com/sct/overseerr/commit/b531a642f601f4ef9bf39c2f5915402157e55372)) +- **ui:** fix tables extending outside viewport in mobile formats ([e270999](https://github.com/sct/overseerr/commit/e270999745f97c2860f6a5b84e897dc6da8d6001)) +- **ui:** Hide 'Mark 4k as Available' button if 4k not enabled ([#833](https://github.com/sct/overseerr/issues/833)) ([e4a50c3](https://github.com/sct/overseerr/commit/e4a50c33f105b440243885d72a9e96595a525447)) +- **ui:** Limit max width of forms & lists ([#845](https://github.com/sct/overseerr/issues/845)) ([b9d14a9](https://github.com/sct/overseerr/commit/b9d14a9fd0f3c94d8267755147a87fe3b77fa2c3)) +- **ui:** prevent names from getting squished in AdvancedRequester user selector ([06e9411](https://github.com/sct/overseerr/commit/06e941171a1d019fbb178624167c026f6df5271c)) +- **ui:** remove yup validation from display name on user edit page ([63d7e2b](https://github.com/sct/overseerr/commit/63d7e2b39858fcb1cc0819a680eebccded7f4451)) +- **ui:** Restore original port input size ([#814](https://github.com/sct/overseerr/issues/814)) ([1ccafc0](https://github.com/sct/overseerr/commit/1ccafc0ebd368d798f9571b83910336efa317e37)) +- **ui:** show request as option even if there are no radarr/sonarr servers ([b116281](https://github.com/sct/overseerr/commit/b116281196c264b4ec35b07f1b4ffa717e50ade5)) +- **ui:** uniform-size checkboxes, vertically-aligned form labels, and fixes for other UI imperfections/inconsistencies ([#737](https://github.com/sct/overseerr/issues/737)) ([e34fbf7](https://github.com/sct/overseerr/commit/e34fbf72fda34d69b9f25563fa81f88b3c20912a)) +- **ui:** Use minimum char validation message ([#850](https://github.com/sct/overseerr/issues/850)) ([7456bea](https://github.com/sct/overseerr/commit/7456bea2ae600a28cb933278ffb310b63a474d6a)) +- **ui:** validate application url and service external urls ([026795d](https://github.com/sct/overseerr/commit/026795d4c940cb4797d3e68089456a4c3defbb21)) +- **ui:** when PersonCard has no profilePath, correctly position name/role content ([3ffd5ab](https://github.com/sct/overseerr/commit/3ffd5ab0ee8ffa63199d1428e37206f9b59fb7a5)) + +### Features + +- **cache:** add cache table and flush cache option to settings ([996bd9f](https://github.com/sct/overseerr/commit/996bd9f14ed0f56767892c169b071be4f0f628d0)) +- **cache:** external API cache ([#786](https://github.com/sct/overseerr/issues/786)) ([20289b5](https://github.com/sct/overseerr/commit/20289b5960a93545cdff9331a1a7b613f382e702)) +- **docker:** Check for /app/config volume mount during setup ([#826](https://github.com/sct/overseerr/issues/826)) ([1e5f88f](https://github.com/sct/overseerr/commit/1e5f88f462b0c69db5f6ab8e0249a5905bc6952a)) +- **frontend:** add TheTVDB external link ([#800](https://github.com/sct/overseerr/issues/800)) ([72cffd7](https://github.com/sct/overseerr/commit/72cffd74a75984ba98c456c0ec006ec378a8dcec)) +- **lang:** add support for Hungarian language ([cfacb15](https://github.com/sct/overseerr/commit/cfacb151b52d08e19d2fcd603fb4bbcd78707cdf)) +- **lang:** translations update from Weblate ([#791](https://github.com/sct/overseerr/issues/791)) ([42295e0](https://github.com/sct/overseerr/commit/42295e076a7579b226d57407a20cb0ba044e9ec1)) +- **lang:** translations update from Weblate ([#819](https://github.com/sct/overseerr/issues/819)) ([9e5e4c2](https://github.com/sct/overseerr/commit/9e5e4c22f5b25df96f47875d599ed8685791382a)) +- **lang:** translations update from Weblate ([#841](https://github.com/sct/overseerr/issues/841)) ([e4f9b8a](https://github.com/sct/overseerr/commit/e4f9b8a9848f3af00e86fc7108c823ed0584609f)) +- **lang:** translations update from Weblate ([#852](https://github.com/sct/overseerr/issues/852)) ([c5be00e](https://github.com/sct/overseerr/commit/c5be00eebfd2b0e65295edbe282cbba22fffa660)) +- **ui:** Add local login setting ([#817](https://github.com/sct/overseerr/issues/817)) ([9d0d5b8](https://github.com/sct/overseerr/commit/9d0d5b86aae025e4647bb664c6412d42192e2fe7)) +- **ui:** added next airing date to TV Shows ([#842](https://github.com/sct/overseerr/issues/842)) ([4eae02a](https://github.com/sct/overseerr/commit/4eae02a7e14e377fd69ddd4a43774cb7e3d1855b)) +- new permission to allow users to see other users requests ([033ba9d](https://github.com/sct/overseerr/commit/033ba9d41bddf6dc1c4512d8404f747e57923bca)), closes [#840](https://github.com/sct/overseerr/issues/840) +- request as another user ([59150f9](https://github.com/sct/overseerr/commit/59150f955f7003672ef19eb9d37156e93b79c97d)) +- **tv:** show cast for the entire show instead of only the last season ([#778](https://github.com/sct/overseerr/issues/778)) ([b239598](https://github.com/sct/overseerr/commit/b239598e64d33b78dc5d7972878840149aff360a)), closes [#775](https://github.com/sct/overseerr/issues/775) +- **ui:** Add custom title functionality ([#825](https://github.com/sct/overseerr/issues/825)) ([35c6bfc](https://github.com/sct/overseerr/commit/35c6bfc0216bf879353b3ee546b439a06c8e6121)) + +# [1.18.0](https://github.com/sct/overseerr/compare/v1.17.2...v1.18.0) (2021-01-30) + +### Bug Fixes + +- **api:** prevent duplicate movie requests ([421f4c1](https://github.com/sct/overseerr/commit/421f4c17f0f206bbe7bfcbf2819014b8c7f55b6a)), closes [#705](https://github.com/sct/overseerr/issues/705) +- **build:** fix sqlite3 build error ([#691](https://github.com/sct/overseerr/issues/691)) ([3a1f6d5](https://github.com/sct/overseerr/commit/3a1f6d5706c8fc100e88425f3d89a26a0325af79)) +- **frontend:** add poster not found image to request card and request list item ([ae9a1b3](https://github.com/sct/overseerr/commit/ae9a1b3e940ac2abf6e842d91f458daab3dd0f0d)) +- **frontend:** add poster not found image to tv details page ([0b05545](https://github.com/sct/overseerr/commit/0b055458d0ddbfd4c87ebf9b0562f161fa3445a3)) +- **frontend:** dont show external links unless slug is set ([946bd2d](https://github.com/sct/overseerr/commit/946bd2db5ecde0748b2e9bc5edfe7ca6000ec3d5)) +- **frontend:** fix server name position on plex settings page ([86efcd8](https://github.com/sct/overseerr/commit/86efcd82c34ad6490f2899ebf6f84cdd1bffc498)) +- **frontend:** fixed mismatched rounded sizing on new login ([5e352c2](https://github.com/sct/overseerr/commit/5e352c201fc2f731ca5f713ecb6901527ef354da)), closes [#721](https://github.com/sct/overseerr/issues/721) +- **ip logging:** add env var for proxy to fix ip logging on failed logins ([#756](https://github.com/sct/overseerr/issues/756)) ([9342a40](https://github.com/sct/overseerr/commit/9342a40bbc03f7fdda23e3876b3a4a81ea8532c0)) +- **lang:** add missing i18n strings for notification settings ([2f75c4c](https://github.com/sct/overseerr/commit/2f75c4c6aed42a15bb47d3652272de8f852ec79f)) +- **notifications:** only send a single notification when standard media becomes available ([b5fd1d5](https://github.com/sct/overseerr/commit/b5fd1d520cd2a7be6e6356a25129e93af1caf542)), closes [#770](https://github.com/sct/overseerr/issues/770) +- **permissions:** use default user permissions when creating a local user ([#713](https://github.com/sct/overseerr/issues/713)) ([660ada0](https://github.com/sct/overseerr/commit/660ada0b2025eb2c06d9054fd0a7b5a632af6af2)) +- **radarr:** fix request bug which made it unable to be added to radarr ([#760](https://github.com/sct/overseerr/issues/760)) ([45a2779](https://github.com/sct/overseerr/commit/45a277964b0c39346d7216873812e0ebe505cb79)) +- **radarr:** return the updated data when updating radarr request ([#765](https://github.com/sct/overseerr/issues/765)) ([0c6d478](https://github.com/sct/overseerr/commit/0c6d4780c355ffe1a951268fb6949491d435bbf1)) +- **requests:** handle when tvdbid is null ([#657](https://github.com/sct/overseerr/issues/657)) ([2da0da8](https://github.com/sct/overseerr/commit/2da0da826ae1d73467bc8a671fda7cc5ca1f14c9)) +- **sonarr-sync:** correctly set series with no seasons to requested status ([3812989](https://github.com/sct/overseerr/commit/3812989a1ce1e07d4af09149008043a6e2e94060)), closes [#762](https://github.com/sct/overseerr/issues/762) +- **sync:** do not update series status if already available and no new seasons ([136d874](https://github.com/sct/overseerr/commit/136d874cba37babf9c0670844b002871710e6d99)), closes [#777](https://github.com/sct/overseerr/issues/777) +- **ui:** Capitalization, punctuation, and grammar inconsistences & errors ([#731](https://github.com/sct/overseerr/issues/731)) ([f05d4a0](https://github.com/sct/overseerr/commit/f05d4a0d0b42905fcaee49b2471bb1f4ee77fffe)) +- lookup movie by imdbid if tmdbid does not exits for plex movie agent ([#711](https://github.com/sct/overseerr/issues/711)) ([e972288](https://github.com/sct/overseerr/commit/e97228899a5936b2525c8060abfa14b5ce31658d)) +- show recently added series even if they are not complete ([d0c830e](https://github.com/sct/overseerr/commit/d0c830e80d389f9e0f48a9b83659331f54630d03)) + +### Features + +- **lang:** translated using Weblate (Dutch) ([059995e](https://github.com/sct/overseerr/commit/059995e0ef3370a3192bd386fa6875ca0f58690a)) +- **lang:** translated using Weblate (French) ([4789583](https://github.com/sct/overseerr/commit/4789583d66305ac7b3d393659b2f3604c0acc576)) +- **lang:** translations update from Weblate ([#727](https://github.com/sct/overseerr/issues/727)) ([71875ef](https://github.com/sct/overseerr/commit/71875efb48246dbb0139ad15a4261a5661fcfe17)) +- **lang:** update languages and fix merge conflict ([083a74a](https://github.com/sct/overseerr/commit/083a74a686d202cce5775bf9752caaa9a626cf45)) +- **ui:** Move PROXY setting to UI ([#782](https://github.com/sct/overseerr/issues/782)) ([f1dd5e7](https://github.com/sct/overseerr/commit/f1dd5e7e12c1f602449c4769173dbce71e3569d0)) +- add manual availability buttons to manage slideover ([67f8aef](https://github.com/sct/overseerr/commit/67f8aef00d98c834b60cb6152ccd5cb7b5709d12)), closes [#672](https://github.com/sct/overseerr/issues/672) +- **media:** add link to the item on plex ([#735](https://github.com/sct/overseerr/issues/735)) ([1d7150c](https://github.com/sct/overseerr/commit/1d7150c24ec5ad347093889bfceab61b664900d5)) +- Radarr & Sonarr Sync ([#734](https://github.com/sct/overseerr/issues/734)) ([ec5fb83](https://github.com/sct/overseerr/commit/ec5fb836785855eb4846fd33b49faeb94c40506a)) +- **frontend:** add option to hide all available items from discovery ([#699](https://github.com/sct/overseerr/issues/699)) ([6c1742e](https://github.com/sct/overseerr/commit/6c1742e94ccfc6c13cf1d25fd9e893ee1f431aae)) +- **lang:** add support for Portuguese (Portugal) language ([e044146](https://github.com/sct/overseerr/commit/e044146aa55109a1eccfde9650b26beb0d5ec9a6)) +- **lang:** translated using Weblate (Dutch) ([6d0f7d4](https://github.com/sct/overseerr/commit/6d0f7d4b50370c420c1017f32d48313074543743)) +- **lang:** translated using Weblate (Italian) ([9aa5c12](https://github.com/sct/overseerr/commit/9aa5c121644518c1fbb308a487c26d8998bb5a36)) +- **lang:** translated using Weblate (Portuguese (Portugal)) ([f001fb3](https://github.com/sct/overseerr/commit/f001fb3b33d4fb749acb70c45b8a55a5bbef570c)) +- **lang:** translated using Weblate (Spanish) ([4f94d22](https://github.com/sct/overseerr/commit/4f94d227fc3096bcb8a1e5cf12fe9222d6c6b711)) +- **login:** add request ip to the failed request log ([#714](https://github.com/sct/overseerr/issues/714)) ([2d31ea9](https://github.com/sct/overseerr/commit/2d31ea940ac0a1a84d2150743798b41ff6490317)) +- **users:** add editable usernames ([#715](https://github.com/sct/overseerr/issues/715)) ([20ca3f2](https://github.com/sct/overseerr/commit/20ca3f2f5fcf4a9eb0d6a8be671bb4fb1f5e6178)) +- pre-populate server info from plex.tv API ([#563](https://github.com/sct/overseerr/issues/563)) ([82ac76b](https://github.com/sct/overseerr/commit/82ac76b0540ba1133cb5384744d2499c2488a4e8)) +- **auth:** Add optional CSRF protection ([#697](https://github.com/sct/overseerr/issues/697)) ([6e25891](https://github.com/sct/overseerr/commit/6e2589178b99f8f32f0ded9a7cfd9921c33e9b60)) +- ability to edit user settings in bulk ([#597](https://github.com/sct/overseerr/issues/597)) ([4b0241c](https://github.com/sct/overseerr/commit/4b0241c3b34d4229f928c21defb10a1c051264d1)) +- **lang:** translated using Weblate (English) ([9bb11af](https://github.com/sct/overseerr/commit/9bb11afc6b4a109ae1e14d41c9fe2b71f19c470a)) +- **lang:** translated using Weblate (German) ([c2a3e8e](https://github.com/sct/overseerr/commit/c2a3e8ed5243925dce991ec7995ae831702dbc7b)) +- **lang:** translated using Weblate (Portuguese (Brazil)) ([32f4916](https://github.com/sct/overseerr/commit/32f4916c4a926097f31ed472aee031536b847bb7)) +- **lang:** translated using Weblate (Portuguese (Brazil)) ([98570c9](https://github.com/sct/overseerr/commit/98570c920e4904a594bb7464161b985094958f84)) +- **notifications:** add option to send notifications for auto-approved requests ([21db367](https://github.com/sct/overseerr/commit/21db3676d1464b63384b04c0c2926cb2a6252e9b)), closes [#267](https://github.com/sct/overseerr/issues/267) + +## [1.17.2](https://github.com/sct/overseerr/compare/v1.17.1...v1.17.2) (2021-01-20) + +### Bug Fixes + +- **requests:** allow declined season requests to be re-requested ([e1032ff](https://github.com/sct/overseerr/commit/e1032ff5dfac4a8c9d4da9cf2788c19822343ad9)), closes [#690](https://github.com/sct/overseerr/issues/690) +- **requests:** update requests to approved when parent media is set as available ([78444a9](https://github.com/sct/overseerr/commit/78444a9e643829823162389dee60cca70da56bff)), closes [#688](https://github.com/sct/overseerr/issues/688) + +## [1.17.1](https://github.com/sct/overseerr/compare/v1.17.0...v1.17.1) (2021-01-19) + +### Bug Fixes + +- **frontend:** show auto approval on series request modal only with correct permissions ([8927c6d](https://github.com/sct/overseerr/commit/8927c6d2e39dbda2b1121095a7273f5cab1c9b74)), closes [#687](https://github.com/sct/overseerr/issues/687) + +# [1.17.0](https://github.com/sct/overseerr/compare/v1.16.0...v1.17.0) (2021-01-19) + +### Bug Fixes + +- **api:** improve rottentomatoes rating matching for movies ([7db62ab](https://github.com/sct/overseerr/commit/7db62ab824eefc42e6db16e42d52f4266b136f82)), closes [#494](https://github.com/sct/overseerr/issues/494) +- **build:** remove cross import from client to server for UserType ([23624bd](https://github.com/sct/overseerr/commit/23624bd144af5df4c31995b68ce48105b95b20f6)) +- **frontend:** clarify which fields are required in radarr/sonarr modals ([860d71e](https://github.com/sct/overseerr/commit/860d71ed69a69a1a3f74b79290ef471e04f57a6b)), closes [#575](https://github.com/sct/overseerr/issues/575) +- **frontend:** do not show failed media status on request list for declined requests ([00944b1](https://github.com/sct/overseerr/commit/00944b1ec2db8ddc5742448f6448f7364c473a98)), closes [#664](https://github.com/sct/overseerr/issues/664) +- **frontend:** fix button styling on details page on small screen sizes ([d9e0c90](https://github.com/sct/overseerr/commit/d9e0c90e76d80aef0c67318e00e997804805f46e)) +- **frontend:** fix request button height ([a262727](https://github.com/sct/overseerr/commit/a2627270784bdef8644875fa5c5a7349a0b7fd81)) +- **frontend:** request dropdown menu now properly shows up over collection button ([b491be1](https://github.com/sct/overseerr/commit/b491be1b1e7f6aa588274230e695e4c5302b961e)) +- **frontend:** show correct request status on request cards for 4k requests ([1aa0005](https://github.com/sct/overseerr/commit/1aa0005b4298fc1af9c1d0bf1f357738c0fa2673)) +- **lang:** add missing see more i18n string for SeeMoreCard ([d9919ab](https://github.com/sct/overseerr/commit/d9919abb8998d28558ddec35b8e60ab2af75d5b7)) +- **lang:** change email auth user/pass strings to SMTP Username/Password ([a77a2aa](https://github.com/sct/overseerr/commit/a77a2aa3ebb1be353d534db5b07647ac26c60e15)) +- **notifications:** correctly compare seasons before sending series notifications ([f17fa2a](https://github.com/sct/overseerr/commit/f17fa2a2db8144bac89936f588627e8dd37bf54a)) +- **notifications:** only send one available notification for standard media ([fc6f7cc](https://github.com/sct/overseerr/commit/fc6f7ccea586165a30022b6d5554911c66ece6df)) +- **notifications:** send media declined email ([eb6fc8a](https://github.com/sct/overseerr/commit/eb6fc8a19099469794d471db0b48a258c2866633)), closes [#679](https://github.com/sct/overseerr/issues/679) +- **plex-sync:** improve plex sync error handling. add session id to fix stuck runs ([a740b07](https://github.com/sct/overseerr/commit/a740b07f06f892b72a651b928af28ce71cb495ee)) +- **plex-sync:** store plex added date and sort recently added by it ([d688a96](https://github.com/sct/overseerr/commit/d688a967596afcba9799b8133089bebb5add27cf)) +- **requests:** select the correct radarr/sonarr server when sending request to service ([e0d9f89](https://github.com/sct/overseerr/commit/e0d9f891e797c3839f976b75a871903b6f2e55f1)) +- **server:** support absolute paths for CONFIG_DIRECTORY ([51d8fba](https://github.com/sct/overseerr/commit/51d8fba9162b9e148a35ced69e7e035438c8b0f1)) +- **user edit:** fix user edit not being able to be saved ([#651](https://github.com/sct/overseerr/issues/651)) ([b04d00e](https://github.com/sct/overseerr/commit/b04d00ef509d6f13c1f9677b3f318331782c0086)) + +### Features + +- **api:** /request/count endpoint ([#682](https://github.com/sct/overseerr/issues/682)) ([192cfd8](https://github.com/sct/overseerr/commit/192cfd8a8ea9ab942d5bb265d42050917a2f5a04)) +- **frontend:** add see more card to media sliders ([587e8db](https://github.com/sct/overseerr/commit/587e8db15e9c19b4c58406e3e4215d8bf87d8762)) +- **frontend:** add template variable help button to custom webhook settings page ([29c5bc4](https://github.com/sct/overseerr/commit/29c5bc40975e7ab0a2e08bb77294f164f0c60769)) +- **lang:** add support for Chinese (Traditional) language ([686c4f7](https://github.com/sct/overseerr/commit/686c4f71bf930625af082ac5e14dc5f79f5c42eb)) +- **lang:** Translations update from Weblate ([#604](https://github.com/sct/overseerr/issues/604)) ([801e765](https://github.com/sct/overseerr/commit/801e76524d6ea0887249f1630402e9c3a3430b44)) +- **login:** add local users functionality ([#591](https://github.com/sct/overseerr/issues/591)) ([492e19d](https://github.com/sct/overseerr/commit/492e19df4014e67dc6a2de5903a33c25e13fcf45)) +- **notifications:** add notification for declined requests ([2f97f61](https://github.com/sct/overseerr/commit/2f97f61a6e8846975774aa16950a39ada2b1a016)), closes [#663](https://github.com/sct/overseerr/issues/663) +- **notifications:** Webhook Notifications ([#632](https://github.com/sct/overseerr/issues/632)) ([a7cc7c5](https://github.com/sct/overseerr/commit/a7cc7c59753dd9649b2ec37eb9d46fe4fa8e1e1c)) +- **requests:** Request Overrides & Request Editing ([#653](https://github.com/sct/overseerr/issues/653)) ([bdb3372](https://github.com/sct/overseerr/commit/bdb33722e6df09dd6d8caa36b104b61c6b8dc00d)) +- **server:** add CONFIG_DIRECTORY env var to control config directory location ([fa8f112](https://github.com/sct/overseerr/commit/fa8f112c31ccb5ee6244f776bc97e76d81958539)) +- 4K Requests ([#559](https://github.com/sct/overseerr/issues/559)) ([6b2df24](https://github.com/sct/overseerr/commit/6b2df24a2e8f96dd2277a814d7e02015d1f80cdc)) +- map AniDB IDs from Hama agent to tvdb/tmdb/imdb IDs ([#538](https://github.com/sct/overseerr/issues/538)) ([0600ac7](https://github.com/sct/overseerr/commit/0600ac7c3a1bc0cdd906634d5f77ea3e99b10e94)), closes [#453](https://github.com/sct/overseerr/issues/453) + +### Reverts + +- **deps:** revert back to next@10.0.3 until sharp optional dependency bug is fixed ([7962964](https://github.com/sct/overseerr/commit/79629645aacc1a042919834da79bff0c1f69c9d6)) + +# [1.16.0](https://github.com/sct/overseerr/compare/v1.15.0...v1.16.0) (2021-01-07) + +### Bug Fixes + +- **frontend:** adjust titlecard badge styling ([effc809](https://github.com/sct/overseerr/commit/effc80977a4ed732092254248f82363e52233171)) +- **frontend:** apply same titlecard hover effect to personcard ([67f2b57](https://github.com/sct/overseerr/commit/67f2b57f00216ded3b34965629d6fdd2f16bc25f)) +- **frontend:** only animate titlecard when showDetail is true ([0ab4c3c](https://github.com/sct/overseerr/commit/0ab4c3c36fe2c1ded142b6931111516f7f990a41)) +- **frontend:** use hardware acceleration for titlecard scale ([88810bf](https://github.com/sct/overseerr/commit/88810bf0a4ef74299f6541b60fa91cea3610f99c)) +- **plex-sync:** do not run plex sync if no admin exists ([493d82b](https://github.com/sct/overseerr/commit/493d82b6b066d77609cf66e005fd1f1472b8e011)) + +### Features + +- **lang:** translations update from Weblate ([#495](https://github.com/sct/overseerr/issues/495)) ([b04eda6](https://github.com/sct/overseerr/commit/b04eda6c8a3bfcaa2a14b8a29612fdf690c9fba0)) +- **lang:** Translations update from Weblate ([#580](https://github.com/sct/overseerr/issues/580)) ([2bfe0f2](https://github.com/sct/overseerr/commit/2bfe0f2bf66956763ab26d5c54f26e6c456f59f7)) +- **notifications:** add pushover integration ([#574](https://github.com/sct/overseerr/issues/574)) ([ee5d018](https://github.com/sct/overseerr/commit/ee5d0181fc9a673b27aefd1d09b0a78c3d2e4f55)) + +# [1.15.0](https://github.com/sct/overseerr/compare/v1.14.1...v1.15.0) (2021-01-04) + +### Bug Fixes + +- **api:** return 202 when same seasons are requested again ([5c84702](https://github.com/sct/overseerr/commit/5c847026aad79fcac4d020786ded9f867696c226)) +- **build:** fixes build to include commit tag for app build step ([289864a](https://github.com/sct/overseerr/commit/289864af1a995ce04834bf8a220cc238e1954d19)) +- **docs:** fix typo in build instructions ([#503](https://github.com/sct/overseerr/issues/503)) ([2b27a71](https://github.com/sct/overseerr/commit/2b27a715b07c27200ba1e5e9623629a34389276d)) +- **frontend:** add i18n for request text on titlecard ([a524b9c](https://github.com/sct/overseerr/commit/a524b9c4c8968f6823d33eb270dc26069fe4a725)) +- **frontend:** add localized strings for status checker ([2dcda39](https://github.com/sct/overseerr/commit/2dcda39d40d820419e098bd6f1101eb820e5b42d)) +- **frontend:** center text in movie auto-approve modal on small screens ([#510](https://github.com/sct/overseerr/issues/510)) ([1438b08](https://github.com/sct/overseerr/commit/1438b08cf0b358d79c6688c64be99f1718ec2d23)), closes [#507](https://github.com/sct/overseerr/issues/507) +- **frontend:** change titlecard to only have a request button ([b5a3a7a](https://github.com/sct/overseerr/commit/b5a3a7a89fcaf86dd794dc419711677b53646577)) +- **frontend:** combine duplicate credits on a persons detail page ([d188f6f](https://github.com/sct/overseerr/commit/d188f6ffadff1564c47d5f33138e35498bed29fd)), closes [#504](https://github.com/sct/overseerr/issues/504) +- **frontend:** disable pointer-events on titlecard badges ([ce06879](https://github.com/sct/overseerr/commit/ce0687922a94588b3492e8ddf2e84f54dd1a0d4e)) +- **frontend:** fix count of requests in request list ([f124d73](https://github.com/sct/overseerr/commit/f124d732a2911abdccb5abc11471efe61cc20f7a)) +- **frontend:** fix sliders overflowing on firefox ([67ac9e0](https://github.com/sct/overseerr/commit/67ac9e075f0ca1cfe7e4766d9168815d7ab600fa)), closes [#566](https://github.com/sct/overseerr/issues/566) +- **frontend:** full season request modal fits on a smaller mobile UI ([#535](https://github.com/sct/overseerr/issues/535)) ([12db7a0](https://github.com/sct/overseerr/commit/12db7a065ad566b47d46de4b949343290894f153)) +- **frontend:** handle currentLibrary possibly being null on first manual sync ([93b57a7](https://github.com/sct/overseerr/commit/93b57a76f10a823615ca11ff59f523b67aa30fad)) +- **frontend:** increase titlecard status badge size on larger screens ([ba106c4](https://github.com/sct/overseerr/commit/ba106c447d76db2f9ac70a60c5b38cc60ab554fe)) +- **frontend:** search clear button now correctly triggers routing ([343f466](https://github.com/sct/overseerr/commit/343f466788abc308b91a414ef61bba816ac8875c)) +- **frontend:** set locale cookie expiration to be much longer ([fae4818](https://github.com/sct/overseerr/commit/fae481895736eab81d52eb93788beb00669fb355)) +- **frontend:** show movie/series badges always ([8cbf39a](https://github.com/sct/overseerr/commit/8cbf39a9d12eaee7720fa4721c350c1ef9dee856)) +- **frontend:** update login/setup images ([058fb65](https://github.com/sct/overseerr/commit/058fb65495baa08a0bd4c9e0aef320c6fc7d017b)) +- **holiday:** remove special holiday slider ([8c09033](https://github.com/sct/overseerr/commit/8c0903393cf2cb2a929ba70a8ab6ddcc4cba0574)) +- correctly deal with tmdb id duplicates between movies/series ([721ed9a](https://github.com/sct/overseerr/commit/721ed9a93087a57ae749388bddcacf26022e3df6)), closes [#526](https://github.com/sct/overseerr/issues/526) +- use new commit tag file for app version as well ([d00e470](https://github.com/sct/overseerr/commit/d00e470b55327489b49d770144b7cfdb24045be6)) + +### Features + +- **email:** add sendername to email notification ([#506](https://github.com/sct/overseerr/issues/506)) ([0185bb1](https://github.com/sct/overseerr/commit/0185bb1a7084c1faeb61fb1c63e34e26732711c8)) +- **frontend:** add clear-field-icon to search field ([#498](https://github.com/sct/overseerr/issues/498)) ([7434a26](https://github.com/sct/overseerr/commit/7434a26f76b5e9f74918f3e1a34443d20ecfcbe4)) +- **frontend:** add documentation link to about page ([c034496](https://github.com/sct/overseerr/commit/c034496f557a031aed35cd28dc7221d8cdf36643)) +- **frontend:** add telegram integration ([#491](https://github.com/sct/overseerr/issues/491)) ([c8d4d67](https://github.com/sct/overseerr/commit/c8d4d674f412082ad9e9da09abd79660365cf728)) +- **frontend:** filter/sorting for request list ([5add44c](https://github.com/sct/overseerr/commit/5add44cfb0379aa6fed7c3b867230292feacc684)), closes [#431](https://github.com/sct/overseerr/issues/431) +- **notifications:** control notifcation types per agent ([8af6a1f](https://github.com/sct/overseerr/commit/8af6a1f566769c583af7dd9e18d162717835b7cc)), closes [#513](https://github.com/sct/overseerr/issues/513) +- status checker to prompt users to reload their frontend when app version changes ([75a4264](https://github.com/sct/overseerr/commit/75a426437a4182e21da13684066966dd5bf8fc5e)) + +## [1.14.1](https://github.com/sct/overseerr/compare/v1.14.0...v1.14.1) (2021-01-02) + +### Bug Fixes + +- **holiday:** remove special holiday slider ([22f2037](https://github.com/sct/overseerr/commit/22f2037ea6c5a0ba2ffa4d69f2b7cf42bdcf8575)) + +# [1.14.0](https://github.com/sct/overseerr/compare/v1.13.0...v1.14.0) (2020-12-25) + +### Bug Fixes + +- **frontend:** add margin to ButtonWithDropdown component on movie/tv details page ([06fc98b](https://github.com/sct/overseerr/commit/06fc98b6b958221fa180f57f702c348f15b31f1c)) +- **frontend:** correctly position title card hover section ([#486](https://github.com/sct/overseerr/issues/486)) ([4b7af86](https://github.com/sct/overseerr/commit/4b7af86111a0300e1a137f23fa4ad1639fa55feb)) +- **frontend:** fix missing styles for alert component ([de3d288](https://github.com/sct/overseerr/commit/de3d288949b60d3a3af889d69a62bea2bc799ed7)) +- **frontend:** fix mobile dropdown in notifications settings ([6353cda](https://github.com/sct/overseerr/commit/6353cda5825f442dd539886c7b9ba437edf27ac4)) +- **frontend:** fix scaling titlecard content position ([bd94740](https://github.com/sct/overseerr/commit/bd947409e6e8ff313011b77adc76ccd5f9112c78)) +- **frontend:** improve flex header on movie/tv details page ([d7b1c28](https://github.com/sct/overseerr/commit/d7b1c2840690c144ebf29a360defcbd6fdb21354)) +- **frontend:** invalid dom-nesting title card fix ([#482](https://github.com/sct/overseerr/issues/482)) ([f2ebba7](https://github.com/sct/overseerr/commit/f2ebba7b1df775d33d2af6abc3ee2c9de5f2e57a)), closes [#476](https://github.com/sct/overseerr/issues/476) +- **frontend:** remove vote permission for now ([5d06a34](https://github.com/sct/overseerr/commit/5d06a347311bd10c05d8f58068ca7104e265dcca)) +- **frontend:** sort person detail credits by tmdb votes ([17518db](https://github.com/sct/overseerr/commit/17518dbe7f545100770a892d03d1f8508adc3650)) +- **frontend:** status badge Unavailable renamed to Requested ([ed94a0f](https://github.com/sct/overseerr/commit/ed94a0f335c59de526dd812aea7616313fe002fd)), closes [#374](https://github.com/sct/overseerr/issues/374) +- **frontend:** update titlecard status badge to new requested colors ([8f292d5](https://github.com/sct/overseerr/commit/8f292d538b937ea133175089979ef02599f6fef4)) +- **logs:** rotate logs on a daily basis instead of incrementing log filename ([395cbb2](https://github.com/sct/overseerr/commit/395cbb2be6c62f1d7573593e49a93615eaf22853)) +- improve apple-touch-icon and android app icons ([329a814](https://github.com/sct/overseerr/commit/329a814a8fb791122266c0b04b05848c71d68ba1)) + +### Features + +- **lang:** translations update from Weblate ([#479](https://github.com/sct/overseerr/issues/479)) ([c8c74b0](https://github.com/sct/overseerr/commit/c8c74b0ae54fcc524aa8b2edf5a5c5e5db6c1638)) +- **notifications:** add slack notification agent ([1163e81](https://github.com/sct/overseerr/commit/1163e81adc7da1e8334155ebee5b4672a22143db)), closes [#365](https://github.com/sct/overseerr/issues/365) +- add collections ([#484](https://github.com/sct/overseerr/issues/484)) ([a333a09](https://github.com/sct/overseerr/commit/a333a095820ce3f10857026ba4770a2fffeed7cb)), closes [#418](https://github.com/sct/overseerr/issues/418) +- add separate auto approve permissions for Movies/Series ([4809257](https://github.com/sct/overseerr/commit/480925781691de456abc427fbbba161be11a3a8a)), closes [#268](https://github.com/sct/overseerr/issues/268) +- simple failed request handling ([#474](https://github.com/sct/overseerr/issues/474)) ([02969d5](https://github.com/sct/overseerr/commit/02969d5426245062a2f53475d83c4a8639632c9d)) +- YouTube Movie/TV Trailers ([#454](https://github.com/sct/overseerr/issues/454)) ([e88dc83](https://github.com/sct/overseerr/commit/e88dc83aeba0475e3ad421d5ab130cea4fc9a806)) + +# [1.13.0](https://github.com/sct/overseerr/compare/v1.12.1...v1.13.0) (2020-12-23) + +### Bug Fixes + +- **api:** correctly return firstAirDate for series in search endpoints ([32b4c99](https://github.com/sct/overseerr/commit/32b4c99950659d9e1da2ffa93c22383c54d0d904)), closes [#462](https://github.com/sct/overseerr/issues/462) +- **email:** correctly log errors when emails fail to send ([0980fa5](https://github.com/sct/overseerr/commit/0980fa54f9fc3bdfae6c57fa5a20ce3b2a88a677)) +- **frontend:** added new Radarr v3 logo ([#471](https://github.com/sct/overseerr/issues/471)) ([3bbc716](https://github.com/sct/overseerr/commit/3bbc716434dc04bfe6b55de9898eb2c0ecb03baa)) +- **frontend:** approve and decline button (in manage panel) will now fit on mobile ([#441](https://github.com/sct/overseerr/issues/441)) ([66ef72d](https://github.com/sct/overseerr/commit/66ef72dd42912d83ea8f86aabb75fbee547f8de9)) +- **frontend:** filter out undefined backdrop paths for person details page ([2e0e4d5](https://github.com/sct/overseerr/commit/2e0e4d5129ed4912415f61eb8d1da41e88ddcaff)) +- **frontend:** show backdrops instead of posters for new person detail design ([9f5f920](https://github.com/sct/overseerr/commit/9f5f920c23007363aa7f53ebef0b61236d4f53ea)) +- clarify full sync runs every 24 hours ([0c8a180](https://github.com/sct/overseerr/commit/0c8a180189b2610bab2fa977d458743d8a60343e)) +- **plex-sync:** match correct tmdb format for movies ([4205e32](https://github.com/sct/overseerr/commit/4205e32ae71bc18c07209f1c82e6af1cb5f01335)) + +### Features + +- **email:** option to allow self signed certificates ([6898357](https://github.com/sct/overseerr/commit/6898357b13a6aa53a55709ea95819c2b3df6784c)) +- **frontend:** adjust person details design and add improved truncate ([1fb7ea7](https://github.com/sct/overseerr/commit/1fb7ea72589d2908ae80a2a688881d4eb3c050e5)) +- **frontend:** first air date added to TV details page ([#470](https://github.com/sct/overseerr/issues/470)) ([a7db01f](https://github.com/sct/overseerr/commit/a7db01fba483ca633a6eb9d39eb085ab9939d4d2)) +- **lang:** translations update from Weblate ([#410](https://github.com/sct/overseerr/issues/410)) ([941fe19](https://github.com/sct/overseerr/commit/941fe1990454439cf05b48ef92bd3493432f8ed8)) +- **logs:** rotate log files if they reach 20MB in size ([22002ab](https://github.com/sct/overseerr/commit/22002ab4c76aace2bb202ac58da605b7a6f75d6d)), closes [#438](https://github.com/sct/overseerr/issues/438) +- **notifications:** include direct links to media in notifications ([659fa50](https://github.com/sct/overseerr/commit/659fa505f0db32262ad0041cddb4daea893e6d65)), closes [#437](https://github.com/sct/overseerr/issues/437) +- **plex-sync:** add support for hama guid's ([ffe9e19](https://github.com/sct/overseerr/commit/ffe9e19c3b99de6af1185900e292da641ff44320)), closes [#453](https://github.com/sct/overseerr/issues/453) + +## [1.12.1](https://github.com/sct/overseerr/compare/v1.12.0...v1.12.1) (2020-12-22) + +### Bug Fixes + +- **migration:** fixes issue migrating away from the unique imdbId constraint ([69fd7a5](https://github.com/sct/overseerr/commit/69fd7a5511215674a5c22ba48627f221da900229)) + +# [1.12.0](https://github.com/sct/overseerr/compare/v1.11.0...v1.12.0) (2020-12-22) + +### Bug Fixes + +- **api:** fix cross-imported type crashing build ([f35dae5](https://github.com/sct/overseerr/commit/f35dae56a583a5545375318fa5be994ae1f2557f)) +- **api:** prevent checking first admin account for plex server access ([22006e9](https://github.com/sct/overseerr/commit/22006e9dbde82609440f89bde9a40887b4742682)) +- **frontend:** add name, short_name and start_url to manifest ([#424](https://github.com/sct/overseerr/issues/424)) ([c6836e0](https://github.com/sct/overseerr/commit/c6836e02c810e8adb12c3a4b110f9604cf5b7b81)) +- **frontend:** adjust person card layout to deal with overflowing content ([4891298](https://github.com/sct/overseerr/commit/48912988915ae40606a900a6f1dd23fc25ed567f)), closes [#416](https://github.com/sct/overseerr/issues/416) +- **frontend:** allow more special characters in search input ([5deb64a](https://github.com/sct/overseerr/commit/5deb64a87fd70e97da27a025ad11fb8ace0e0b57)), closes [#430](https://github.com/sct/overseerr/issues/430) +- **logs:** improve logging when adding to sonarr/radarr ([4b50522](https://github.com/sct/overseerr/commit/4b505223b881a750007e3fbc7d4bcb9677d4d412)) +- only run migrations in production ([ab9cef3](https://github.com/sct/overseerr/commit/ab9cef3624b5db1ec03507553a69d33b87857e29)) +- **notifications:** always update the media table when seasons become available ([0916b58](https://github.com/sct/overseerr/commit/0916b58594a00db98c6701fdcaee4f3c3e08904e)) +- **plex-sync:** fixes processing movies using TMDB agent ([764db94](https://github.com/sct/overseerr/commit/764db94f1bd7866309684d5bd56033b21cbc2e0c)), closes [#363](https://github.com/sct/overseerr/issues/363) + +### Features + +- **frontend:** add crew related movies/shows to person details page ([12127a7](https://github.com/sct/overseerr/commit/12127a77633f0e92ae88cbafd49581296f559c33)) +- **frontend:** add full crew page for movies/shows ([604ba2a](https://github.com/sct/overseerr/commit/604ba2a92f1d59489e7fc6dfc011347f8595c123)) +- default user permissions added to settings ([e7ee85c](https://github.com/sct/overseerr/commit/e7ee85c29b5d25c6bff58717eae5e62de4dcef0c)), closes [#388](https://github.com/sct/overseerr/issues/388) +- import users from plex ([#428](https://github.com/sct/overseerr/issues/428)) ([7e8f361](https://github.com/sct/overseerr/commit/7e8f361af711001cfc4dcc06a384b76f9846f90f)), closes [#281](https://github.com/sct/overseerr/issues/281) +- **frontend:** add prioritized crew under overview ([6753d9d](https://github.com/sct/overseerr/commit/6753d9daaafb18672f14fd86f2c1675dcec39b13)), closes [#406](https://github.com/sct/overseerr/issues/406) +- **notifications:** added ability to send test notifications ([44a3054](https://github.com/sct/overseerr/commit/44a305426f3e9829c167a4a73095d0d248641f47)), closes [#309](https://github.com/sct/overseerr/issues/309) + +### Reverts + +- **deps:** revert react-use-clipboard to 1.0.2 ([7083ddf](https://github.com/sct/overseerr/commit/7083ddf18121716e3442acab3506c395fdc351ac)) + +# [1.11.0](https://github.com/sct/overseerr/compare/v1.10.0...v1.11.0) (2020-12-20) + +### Features + +- **frontend:** add language picker to setup/login ([ff2ab29](https://github.com/sct/overseerr/commit/ff2ab29491a80c421525b9a394d6fbbf54914dc2)) +- **frontend:** add support overseerr block to about page ([c128898](https://github.com/sct/overseerr/commit/c128898206d6cbb482de4d8dca53f70b87e4911a)) +- **frontend:** releases added to about page ([b7f5739](https://github.com/sct/overseerr/commit/b7f573903500cc8a62e39afd787bc1da8c09d88b)), closes [#303](https://github.com/sct/overseerr/issues/303) +- **lang:** add support for Italian, Portuguese (Brazil) and Serbian ([108dfc4](https://github.com/sct/overseerr/commit/108dfc4afd31388cb6c9e07deccd168ade8b1574)) +- **lang:** add support for swedish language ([c9fe6cb](https://github.com/sct/overseerr/commit/c9fe6cb0b7ea984d8e4e1cb3f284935c9da7cc2b)) +- **lang:** translations update from Weblate ([#400](https://github.com/sct/overseerr/issues/400)) ([1bd0e64](https://github.com/sct/overseerr/commit/1bd0e646e313ddf77ef331e818e03401fbf64a72)) +- **lang:** translations update from Weblate ([#403](https://github.com/sct/overseerr/issues/403)) ([3778ad8](https://github.com/sct/overseerr/commit/3778ad829c0897de178212b3bde4c0d3b5089161)) + +# [1.10.0](https://github.com/sct/overseerr/compare/v1.9.1...v1.10.0) (2020-12-19) + +### Bug Fixes + +- **email:** fix link to Overseerr in email templates ([816fec1](https://github.com/sct/overseerr/commit/816fec1a83a53edb3b65c3e5e7d0e6e1bd49726d)), closes [#392](https://github.com/sct/overseerr/issues/392) +- **frontend:** adjust padding of search box so placeholder text fits on mobile ([3601d44](https://github.com/sct/overseerr/commit/3601d442db32d3f98f7b050365c11ea8ef9bc4ae)), closes [#393](https://github.com/sct/overseerr/issues/393) +- **frontend:** changed request block for slideover on mobile UI ([#387](https://github.com/sct/overseerr/issues/387)) ([549567a](https://github.com/sct/overseerr/commit/549567a7e9db01933546d9970fc06f17218dfab1)) +- **frontend:** hide Request More button if all current seasons are available ([2a4dd52](https://github.com/sct/overseerr/commit/2a4dd52275007e48f946c3b9e29f1d78da57bdaa)), closes [#343](https://github.com/sct/overseerr/issues/343) +- **frontend:** try not to render broken rottentomatoes data ([a0c5608](https://github.com/sct/overseerr/commit/a0c5608aa0b6c7a4294300589efa9a662163ce48)) + +### Features + +- **lang:** translations update from Weblate ([#391](https://github.com/sct/overseerr/issues/391)) ([5f71fb7](https://github.com/sct/overseerr/commit/5f71fb7ee280714275d2ac045c472fcdddd5a2ea)) +- add missing tzdata package to image ([53bede6](https://github.com/sct/overseerr/commit/53bede692d4f0e940dededa63015fe1908129914)), closes [#394](https://github.com/sct/overseerr/issues/394) +- **frontend:** add external links to movie and tv detail pages ([a0024a0](https://github.com/sct/overseerr/commit/a0024a0cbe717d78f53413bb78644c829f143c4d)) +- **lang:** translations update from Weblate ([#380](https://github.com/sct/overseerr/issues/380)) ([8408e19](https://github.com/sct/overseerr/commit/8408e19568b2f239c57e11e2946c75f193d1c22e)) + +## [1.9.1](https://github.com/sct/overseerr/compare/v1.9.0...v1.9.1) (2020-12-18) + +### Bug Fixes + +- change default internal port to 5055 ([#389](https://github.com/sct/overseerr/issues/389)) ([5e5ba40](https://github.com/sct/overseerr/commit/5e5ba4050563f07bff367d2fb31ed7e7fca4291e)) + +# [1.9.0](https://github.com/sct/overseerr/compare/v1.8.0...v1.9.0) (2020-12-18) + +### Features + +- api key regeneration ([6beac73](https://github.com/sct/overseerr/commit/6beac736efcf7b9102e02e43b75d91a9a158cd22)) +- **api:** add movie keyword search ([f88c4a6](https://github.com/sct/overseerr/commit/f88c4a6d4a49f8f3451ba6c85153677f33b7f5f6)) +- **frontend:** add studio/networks to movie/tv details ([4b6ad8a](https://github.com/sct/overseerr/commit/4b6ad8a3871957db4192b603abf38404250cea5d)), closes [#370](https://github.com/sct/overseerr/issues/370) +- **frontend:** added user deletion to the user list ([727fa06](https://github.com/sct/overseerr/commit/727fa06c18febb2a97ca219cc6bf0277ff462acd)), closes [#348](https://github.com/sct/overseerr/issues/348) +- **holiday:** special seasonal slider added to discover :) ([908f635](https://github.com/sct/overseerr/commit/908f63557ca03a1da8b16809ffa2c3acd782d94e)) +- allow to listen server on specific host interface ([#381](https://github.com/sct/overseerr/issues/381)) ([086183b](https://github.com/sct/overseerr/commit/086183b5636aa8d075d01fe59492c3eab0d1345b)), closes [#273](https://github.com/sct/overseerr/issues/273) +- anime profile support ([#384](https://github.com/sct/overseerr/issues/384)) ([0972f40](https://github.com/sct/overseerr/commit/0972f40a4e1fb3b5f02b07ae46b997d71aab9bfb)), closes [#266](https://github.com/sct/overseerr/issues/266) + +# [1.8.0](https://github.com/sct/overseerr/compare/v1.7.0...v1.8.0) (2020-12-17) + +### Features + +- **lang:** translations update from Weblate ([#336](https://github.com/sct/overseerr/issues/336)) ([ee84f74](https://github.com/sct/overseerr/commit/ee84f74f8a3558875b41daa539f42d00b949898a)) + +# [1.7.0](https://github.com/sct/overseerr/compare/v1.6.0...v1.7.0) (2020-12-17) + +### Bug Fixes + +- **email:** do not pass auth object to transport if no auth data present ([d5eb4d8](https://github.com/sct/overseerr/commit/d5eb4d8d438a159266b2de66b6bcdd9440a0c8ef)), closes [#312](https://github.com/sct/overseerr/issues/312) +- **frontend:** add http/https prefix to hostname fields for plex/radarr/sonarr ([ce0266f](https://github.com/sct/overseerr/commit/ce0266f74ea3979b291ff962271a928682892788)), closes [#357](https://github.com/sct/overseerr/issues/357) +- **frontend:** clarify that radarr/sonnarr servers must be tested before profiles/folders appear ([fc12ab8](https://github.com/sct/overseerr/commit/fc12ab84d9482eb3a11f117f8cab6fd48a9401cd)), closes [#326](https://github.com/sct/overseerr/issues/326) [#328](https://github.com/sct/overseerr/issues/328) +- **frontend:** correctly show an unauthorized error when a user fails to login ([18925de](https://github.com/sct/overseerr/commit/18925decafdac518f52a354c594cc378d2529022)), closes [#322](https://github.com/sct/overseerr/issues/322) +- **frontend:** fix tv shows failing to open when firstAirDate is undefined ([c21fa5b](https://github.com/sct/overseerr/commit/c21fa5b5350abdd8e03c077fde7246fa398e176e)), closes [#347](https://github.com/sct/overseerr/issues/347) +- **frontend:** make minimum availability required for Radarr servers ([2fe53ec](https://github.com/sct/overseerr/commit/2fe53ec5a8534e75c7d0cef31a8b46065111e0a7)), closes [#345](https://github.com/sct/overseerr/issues/345) +- **plex-sync:** bundle duplicate ratingKeys to speed up recently added sync ([67146c3](https://github.com/sct/overseerr/commit/67146c33ef7f28d520ba2c50b32673d43f4525c8)), closes [#360](https://github.com/sct/overseerr/issues/360) +- **sonarr.ts, mediarequest.ts:** add missing seasonFolder option ([#358](https://github.com/sct/overseerr/issues/358)) ([e9c899c](https://github.com/sct/overseerr/commit/e9c899ce419d149dde2ad9a0f7d5a2f2545b3ebf)) + +### Features + +- **frontend:** show alert when there are no default radarr/sonarr servers ([0d088e0](https://github.com/sct/overseerr/commit/0d088e085e68d39455fda21d1fd08ebcaef2c06b)), closes [#344](https://github.com/sct/overseerr/issues/344) + +# [1.6.0](https://github.com/sct/overseerr/compare/v1.5.0...v1.6.0) (2020-12-16) + +### Bug Fixes + +- **api:** accept the api key to perform actions on the api with X-API-Key header ([33f8831](https://github.com/sct/overseerr/commit/33f8831e880dc7fd3f69d951246cada5c6c0ffe7)) +- **api:** filter out libraries that do not have any metadata agent or are not movie/show ([01c179f](https://github.com/sct/overseerr/commit/01c179f762e686a1e5a3d4dab3a5bea53425b575)) +- **api:** only run recently added sync on enabled libraries ([e08fa35](https://github.com/sct/overseerr/commit/e08fa35548bb8644afa8df3124e6f9cc3a2c8f4a)), closes [#259](https://github.com/sct/overseerr/issues/259) +- **api:** set plex libraries to disabled if the name changes ([675060b](https://github.com/sct/overseerr/commit/675060bcdf23acbfd4de2900a65f95e74f4966a5)), closes [#324](https://github.com/sct/overseerr/issues/324) +- **frontend:** adds a tip to plex setup to clarify that syncing runs in the background ([df4ac83](https://github.com/sct/overseerr/commit/df4ac8361f82971ee845f3be217408a9123a0bf3)), closes [#325](https://github.com/sct/overseerr/issues/325) +- **frontend:** aligned movie and tv details ([#331](https://github.com/sct/overseerr/issues/331)) ([db0a5c4](https://github.com/sct/overseerr/commit/db0a5c44f678e76eee7f5582381016306d1f46a2)) +- **frontend:** close sidebar when clicking outside ([#333](https://github.com/sct/overseerr/issues/333)) ([6d7907e](https://github.com/sct/overseerr/commit/6d7907e844a909993d185759d660632f55aeaa35)) +- spelling mistake on the word 'requested' fixed ([#319](https://github.com/sct/overseerr/issues/319)) ([961d110](https://github.com/sct/overseerr/commit/961d1107208069a6fc820a1ba97ffda7336677cb)) + +### Features + +- add version to startup logs ([2948f93](https://github.com/sct/overseerr/commit/2948f9360eb484d1d6c0740a840135ca97e7240a)) +- **frontend:** temporary logs page to clear up confusion about it 404ing ([d9788c4](https://github.com/sct/overseerr/commit/d9788c4aa9f87e2eda3f7e3f1adc985f16039552)), closes [#272](https://github.com/sct/overseerr/issues/272) +- **lang:** add support for Spanish language ([6cd2049](https://github.com/sct/overseerr/commit/6cd20491d2a0ceb995c4744eeb92a6e2f57a4893)) +- **lang:** Translations update from Weblate ([#291](https://github.com/sct/overseerr/issues/291)) ([fddbb3c](https://github.com/sct/overseerr/commit/fddbb3cdfe3d50b2835c248556139c769dc2b805)) + +# [1.5.0](https://github.com/sct/overseerr/compare/v1.4.0...v1.5.0) (2020-12-15) + +### Bug Fixes + +- **api:** require package.json directly so typescript doesnt compile it into dist folder ([b9faa64](https://github.com/sct/overseerr/commit/b9faa6486b35aa865019aa8af9d307531054bc1d)) +- **frontend:** add validation for Radarr/Sonarr server name ([b5988f9](https://github.com/sct/overseerr/commit/b5988f9a5ff274e97f208c2726abe76c22c858ee)) +- **frontend:** only show alpha notice to admins ([ff61895](https://github.com/sct/overseerr/commit/ff618956b5d9cf933d867ea979b612c3d8a6f30b)) +- add support for ssl when connecting to plex ([3ba09d0](https://github.com/sct/overseerr/commit/3ba09d07eb0367c41603cd55e7ff41c66fb641c4)), closes [#275](https://github.com/sct/overseerr/issues/275) +- **services:** improve logging for when Radarr movie already exists ([#285](https://github.com/sct/overseerr/issues/285)) ([f998873](https://github.com/sct/overseerr/commit/f998873fc5669a547901f2733c9c785d744d27ca)), closes [#260](https://github.com/sct/overseerr/issues/260) + +### Features + +- **lang:** add i18n strings for new about page ([900827b](https://github.com/sct/overseerr/commit/900827be97845688e4bea72a8c5d9611a3e9d069)) +- about page initial version ([3f2a04c](https://github.com/sct/overseerr/commit/3f2a04c881bf06b73a952181fa463af84454b0dd)) + +# [1.4.0](https://github.com/sct/overseerr/compare/v1.3.2...v1.4.0) (2020-12-15) + +### Bug Fixes + +- changing parameter name to use correct 'port' [#276](https://github.com/sct/overseerr/issues/276) ([#277](https://github.com/sct/overseerr/issues/277)) ([6d08b10](https://github.com/sct/overseerr/commit/6d08b108200177ca3068c852e60a0df75ce2232a)) +- **services:** include radarr/sonarr baseUrl when adding media ([78af1a3](https://github.com/sct/overseerr/commit/78af1a3e6d00a5645a05e7bf3cf56a59439b6cc9)) + +### Features + +- **lang:** Translations update from Weblate ([#240](https://github.com/sct/overseerr/issues/240)) ([e17c637](https://github.com/sct/overseerr/commit/e17c63748362b6a480693e003ef5eec614dcec43)) + +## [1.3.2](https://github.com/sct/overseerr/compare/v1.3.1...v1.3.2) (2020-12-14) + +### Bug Fixes + +- **frontend:** convert plex port to a number before posting to the api ([8cb05c4](https://github.com/sct/overseerr/commit/8cb05c413a15a4b74e37ece5e24367d115995b32)) +- **frontend:** converts email smtp port to a number before posting to the api ([2098a2d](https://github.com/sct/overseerr/commit/2098a2d3d2981fd2ae54392aec3ef81327f2858e)), closes [#251](https://github.com/sct/overseerr/issues/251) +- **frontend:** encode special characters in search input to prevent crashing router ([15013d6](https://github.com/sct/overseerr/commit/15013d6c5dbff15704c7c30d261d68a265e7f2d7)), closes [#252](https://github.com/sct/overseerr/issues/252) +- **plex sync:** catch errors that occur during processMovie ([edbbccf](https://github.com/sct/overseerr/commit/edbbccf3ae623430294f1a5c3fd2728dbd42e555)), closes [#244](https://github.com/sct/overseerr/issues/244) [#246](https://github.com/sct/overseerr/issues/246) [#250](https://github.com/sct/overseerr/issues/250) +- **services:** improve logging for adding movies to Radarr ([6c1ee83](https://github.com/sct/overseerr/commit/6c1ee830a183f89bb1fe96a181a7d61684e23b22)) +- **services:** radarr/sonarr will use the correct default server ([0658b79](https://github.com/sct/overseerr/commit/0658b7943e1ab25816db9da34d4c9ea808d9203d)) + +## [1.3.1](https://github.com/sct/overseerr/compare/v1.3.0...v1.3.1) (2020-12-14) + +### Bug Fixes + +- **frontend:** also convert activeProfileId to a number for radarr/sonarr submissions ([7bf924f](https://github.com/sct/overseerr/commit/7bf924f7e94a0e0834f41b4ec067ed277c652766)) +- **frontend:** also convert ports to numbers when saving radarr/sonarr servers ([c53dc3b](https://github.com/sct/overseerr/commit/c53dc3b15da522c6e6ab76bbc9d15008a8a9fb9d)) +- **frontend:** new radarr/sonarr ports will be converted to a number before posting ([92c9001](https://github.com/sct/overseerr/commit/92c9001c9d1f2cbd272a5897ea1157d2cadbce2d)) + +# [1.3.0](https://github.com/sct/overseerr/compare/v1.2.0...v1.3.0) (2020-12-14) + +### Bug Fixes + +- **api:** correctly generate clientId on first startup ([5f09e83](https://github.com/sct/overseerr/commit/5f09e83ed870336638d3e9d94fcf55ead928e737)) + +### Features + +- **frontend:** add full cast page for movies and series ([051f1b3](https://github.com/sct/overseerr/commit/051f1b3e899bf749e632743e5c8d45a02b621998)) +- **lang:** translated using Weblate (Dutch) ([1ab3a4b](https://github.com/sct/overseerr/commit/1ab3a4b80a081d7e4a201f1290cd270ed5b38ac7)) +- **lang:** translated using Weblate (English) ([0949c9b](https://github.com/sct/overseerr/commit/0949c9b334b3a4b6c342517a157a9e2b7596f2f0)) +- **lang:** translated using Weblate (French) ([f943701](https://github.com/sct/overseerr/commit/f943701e13c7f0de5a711302597858cc898b16e2)) +- **lang:** translated using Weblate (French) ([30d04ce](https://github.com/sct/overseerr/commit/30d04ce35adc21070cce37ab10384154afda191b)) +- **lang:** translated using Weblate (German) ([7bf9add](https://github.com/sct/overseerr/commit/7bf9addd13a707aac23b64ef3f1733e491d40a4e)) +- **lang:** translated using Weblate (German) ([b6e60a4](https://github.com/sct/overseerr/commit/b6e60a412b30907aea751a4cf1ce0cc8230f9814)) +- **lang:** translated using Weblate (Japanese) ([08e968f](https://github.com/sct/overseerr/commit/08e968fd0097ec7b2a65de064ed5b07e7c49ef39)) +- **lang:** translated using Weblate (Norwegian Bokmål) ([83efb0e](https://github.com/sct/overseerr/commit/83efb0e3d4d96b6a2d2ebdd85d36c9d78c1717b2)) +- **lang:** translated using Weblate (Russian) ([0d8e0d0](https://github.com/sct/overseerr/commit/0d8e0d0352f72fdb65ee8f054371eae08c39fe33)) + +# [1.2.0](https://github.com/sct/overseerr/compare/v1.1.0...v1.2.0) (2020-12-11) + +### Bug Fixes + +- **frontend:** person cards now show correctly in ListView's ([ccb9855](https://github.com/sct/overseerr/commit/ccb98553f104c1aebd33796b7090cc9bbe964bd7)) +- **frontend:** properly remove site overlay when closing modals ([3fa7ff9](https://github.com/sct/overseerr/commit/3fa7ff9858d14d132151f3329164d55d74638f53)) +- **frontend:** switch to using Transition component for modals ([b16fbaf](https://github.com/sct/overseerr/commit/b16fbafa1f3d5e105c0a4ba6f1d66aa064019636)), closes [#220](https://github.com/sct/overseerr/issues/220) +- fix missing personid in Discover ([d8060af](https://github.com/sct/overseerr/commit/d8060afe02574337f51b88cab0a0f824976ac721)) +- missing personId in ListView component ([6502feb](https://github.com/sct/overseerr/commit/6502feb1a5be3c6daab33230814fe74632c87f7e)) +- **frontend:** update overflow issues with seasons + email ([#217](https://github.com/sct/overseerr/issues/217)) ([2d0afb2](https://github.com/sct/overseerr/commit/2d0afb29d37798a626e3f182571ccce43d80063c)), closes [#216](https://github.com/sct/overseerr/issues/216) +- **lang:** fix missing i18n string for agent enabled in email notification page ([42788ad](https://github.com/sct/overseerr/commit/42788adb75f7d23e68327688b1c542dd047e9609)) + +### Features + +- **lang:** update language files ([8cd067b](https://github.com/sct/overseerr/commit/8cd067b6e9df1a3c8f4056789436a31177703986)) +- person details page ([d6eb3ae](https://github.com/sct/overseerr/commit/d6eb3ae64ef46bd62145010d3029e272676487c3)) +- **lang:** add nb-NO and de language support to app ([d38b28d](https://github.com/sct/overseerr/commit/d38b28d2061b38366989ff412957a5dee5766c6f)) +- **lang:** add support for dutch language ([df94db0](https://github.com/sct/overseerr/commit/df94db050bf68a925118e0ce865d27178b702f9e)) +- **lang:** add support for russian languge ([8d8e750](https://github.com/sct/overseerr/commit/8d8e7509826514eebc859374d2e1ab212cc442d1)) +- **lang:** added translation using Weblate (Russian) ([887f5dd](https://github.com/sct/overseerr/commit/887f5dd487b61676029652d99cbc5b40213aa22e)) +- **lang:** translated using Weblate (French) ([30a8934](https://github.com/sct/overseerr/commit/30a8934626fa2d47e95b5925d7e4227a0d0aa728)) +- **lang:** translated using Weblate (German) ([44dbb74](https://github.com/sct/overseerr/commit/44dbb745b6216ce19fab4740520785c6414cf367)) +- **lang:** translated using Weblate (Japanese) ([a494507](https://github.com/sct/overseerr/commit/a494507dfeafb0cfd2bd66fb01138522e0e80737)) +- **lang:** translated using Weblate (Russian) ([86cadb8](https://github.com/sct/overseerr/commit/86cadb8283fcab8745b4c09f8429fd9e46708813)) +- **lang:** translations update from Weblate ([#201](https://github.com/sct/overseerr/issues/201)) ([b0c663b](https://github.com/sct/overseerr/commit/b0c663baccd994e234b4d41d86486c3af4906344)) + +# [1.1.0](https://github.com/sct/overseerr/compare/v1.0.0...v1.1.0) (2020-12-08) + +### Bug Fixes + +- fix a few misc unused imports and useless assignments/conditionals ([8e6daf7](https://github.com/sct/overseerr/commit/8e6daf7bd271ce5bebf4a00f5bb1144bd6b60aa5)) +- **frontend:** dont show delete button in request list for users without correct permission ([83fde46](https://github.com/sct/overseerr/commit/83fde46a59c6f1910806a6106b5526b8adbc386c)) +- **frontend:** push updated i18n locale files ([b4002d7](https://github.com/sct/overseerr/commit/b4002d71323a04e7991198cedc263660e872df8d)) + +### Features + +- generate real api key ([a839370](https://github.com/sct/overseerr/commit/a8393707fec85a9262af5ba8c03d205190b2235b)) +- **frontend:** add i18n strings for request list and request item ([6c4022f](https://github.com/sct/overseerr/commit/6c4022fb236583ad20d4c4c6693c1339e165b4af)) +- **frontend:** initial version of the requests page (no filtering/sorting) ([1ba027b](https://github.com/sct/overseerr/commit/1ba027b4357e078c3f177d9d07208049f0c1ce65)) +- **frontend:** only load request/tmdb cards when in the browser view ([2d51efd](https://github.com/sct/overseerr/commit/2d51efd71612ec969b83c62d6aa0dac6df9391a3)) + +# 1.0.0 (2020-12-06) + +### Bug Fixes + +- **api:** fix scheduling for plex full sync (maybe) ([7287a6a](https://github.com/sct/overseerr/commit/7287a6a95703b23acc0c4f6eb3beb9ec2295e33f)) +- **frontend:** always show request modal option for tv ([2b46268](https://github.com/sct/overseerr/commit/2b462688243531b4be620a942f59defd4e0534d0)) +- **frontend:** canceled movie request should set parent movie status back to unknown ([#198](https://github.com/sct/overseerr/issues/198)) ([139871f](https://github.com/sct/overseerr/commit/139871f218812a15f742aa66408db12704e0b9b5)) +- **frontend:** close request modals when complete ([85ae499](https://github.com/sct/overseerr/commit/85ae4998f0ba8d4869b9b244f2c440b9df1310d2)) +- **frontend:** dont show runtime if there is no runtime data ([e0c39ae](https://github.com/sct/overseerr/commit/e0c39aeca119b822f2a54ff05a97f91780ddd052)) +- **frontend:** fix missing data for request modal title i18n ([a56fd16](https://github.com/sct/overseerr/commit/a56fd16ab6638d4649fe9f8b9d75e7cae7742f73)) +- **frontend:** fix missing import for ReactNode type in Slider ([b26a234](https://github.com/sct/overseerr/commit/b26a2347e7b0f7ff8720a204d9faefd501ba886c)) +- **frontend:** fix modal design and rename some text for adding servers ([46d99b0](https://github.com/sct/overseerr/commit/46d99b02b1c992c7b8dde2150217ed9ce326b7a5)) +- **frontend:** fix opening popups on safari ([364d9d1](https://github.com/sct/overseerr/commit/364d9d105ca3690fcd5f635485d7c025353bb9f1)) +- **frontend:** fix request card placeholder sizes for mobile ([ef62c67](https://github.com/sct/overseerr/commit/ef62c67480ed52d753ea6db8205f035b2e9da272)) +- **frontend:** show a badge on requestcard for partially available status ([59056c4](https://github.com/sct/overseerr/commit/59056c44f942a37df536ff947b5faccc27f32246)) +- dont cross import SyncStatus type ([e032e38](https://github.com/sct/overseerr/commit/e032e385a5253d215490255c676f42ee48f39428)) +- fix type import from server side crashing build process ([89be56d](https://github.com/sct/overseerr/commit/89be56d8403ebc60c411e7cb357593edd9c79bb2)) +- **frontend:** fix title detail background image to be centered ([b92f64f](https://github.com/sct/overseerr/commit/b92f64fa6e167bc89168d8f5c0f2eb12efa0b6f0)) +- **frontend:** fixed similar/recommendations showing when empty ([#180](https://github.com/sct/overseerr/issues/180)) ([a3ca9b4](https://github.com/sct/overseerr/commit/a3ca9b40c552e6cc5effc2f57f7562ff6f723e42)) +- **frontend:** have tvDetail use the new RequestModal ([6aca826](https://github.com/sct/overseerr/commit/6aca82607b97d4a4ad74e2ea843d52fba4689e6a)) +- **frontend:** reinitalize plex form after data loads ([97e3036](https://github.com/sct/overseerr/commit/97e30367fb5d2d27efc42c1d76b0d051b6f1da76)) +- **frontend:** remove requestId from tilecard request modal component ([61b6152](https://github.com/sct/overseerr/commit/61b6152e8915c99585b944756a61d33b8c8a0307)) +- **frontend:** run initial props for children components after getting the user ([fdf9f38](https://github.com/sct/overseerr/commit/fdf9f38776b6d4c08b3505c03b354639cebb011f)) +- **frontend:** when there were no results in the list view, it would call fetch more infinitely ([c0ce87b](https://github.com/sct/overseerr/commit/c0ce87b6f65bf0ab1301c7ca61090d779709529f)) +- fixed an issue with eslint-prettier on windows ([#32](https://github.com/sct/overseerr/issues/32)) ([b673ea1](https://github.com/sct/overseerr/commit/b673ea1b18ca0f432996bb9e4e5d148af0247170)) +- fixes next.js build to not include server files ([de8ee9b](https://github.com/sct/overseerr/commit/de8ee9ba85e0160b0b472cab44f92c01796efec8)) + +### Features + +- add migration for delete cascades on season requests/seasons ([c688cf6](https://github.com/sct/overseerr/commit/c688cf60c710f0cf0b2da5ba6b0c18a2d137e7f9)) +- **api:** email notification agent ([0962392](https://github.com/sct/overseerr/commit/0962392e3930c7fdcb3164b9143cc8faca38bdfa)) +- **frontend:** add french language file ([cd6d8a8](https://github.com/sct/overseerr/commit/cd6d8a8216e7ae183b046d26cd22f3c1dc1d2b35)) +- **frontend:** add translatable strings for request card ([0d2f360](https://github.com/sct/overseerr/commit/0d2f360c22cd9bb50ae04f00a25e5fcc6c21bcdd)) +- **frontend:** added more localized strings ([659a601](https://github.com/sct/overseerr/commit/659a6018777718f7a90141307678d8dadcfd77f8)) +- actually include email templates in built server files ([a28a8b3](https://github.com/sct/overseerr/commit/a28a8b37b0afc79583e4a7191a91f73ff6d3adad)) +- add application url config to main settings ui ([a359672](https://github.com/sct/overseerr/commit/a359672ebafffef742858814f0faa918e0341aa3)) +- add filtering for requests api ([cb9ae25](https://github.com/sct/overseerr/commit/cb9ae25d94f21e97113dfea3ca45c7002089e344)) +- add trending to discover page ([ff8b9d8](https://github.com/sct/overseerr/commit/ff8b9d8e7ed228a153c2da4d237f7a4f99a79321)) +- force setup if app is not initialized ([a99705f](https://github.com/sct/overseerr/commit/a99705f6a5674b436ae28cbc558f4ee6e99ac910)) +- initial user list (no edit/delete yet) and job schedules ([24a0423](https://github.com/sct/overseerr/commit/24a0423f3b14303cfb0e83aef6e9e3bb273c5ba9)) +- manage series slideover added (and approve/decline/delete hooked up) ([236c4e5](https://github.com/sct/overseerr/commit/236c4e5e6126d2424a4badc08b7f7e6d1d70f401)) +- media delete option in manage media slideover ([250f484](https://github.com/sct/overseerr/commit/250f48492c95d74e40d95d3f026d2952157bc6e1)) +- other email notifications for approved/available ([0d73d88](https://github.com/sct/overseerr/commit/0d73d88f35b03e993f305873dc72672003c7d9e5)) +- radarr edit/create modal/backend functionality ([c4ac357](https://github.com/sct/overseerr/commit/c4ac357ef4cdd7a2c610260db46a4f0c325cd785)) +- season creation migration ([978f92a](https://github.com/sct/overseerr/commit/978f92a1c589ac404a3cb1103a68a8a5ffb0dd7d)) +- sonarr edit/delete modal ([3204326](https://github.com/sct/overseerr/commit/320432657e6ccf4d255238098e03590f28267bdb)) +- throw 404 when movie/tv show doesnt exist ([0601b44](https://github.com/sct/overseerr/commit/0601b446873e2eaf042044dd6a995b713586b0cc)) +- **api:** sonarr api wrapper / send to sonarr ([9385592](https://github.com/sct/overseerr/commit/9385592362eeba1dba05c5aa8fc7a2de1d054d74)) +- **frontend:** add header styling to movie/tv recommendation and similar list views ([f5f2545](https://github.com/sct/overseerr/commit/f5f2545520a43daa23e1276d24ff60d794ebbc6e)) +- **frontend:** add links to detail pages from new request card ([6ad3384](https://github.com/sct/overseerr/commit/6ad3384a78f7bcb03f409cce8b35cc61d634d6b2)) +- **frontend:** new design for request card ([93738e1](https://github.com/sct/overseerr/commit/93738e154c41fd11d5c6cf3d35573daf54ead471)) +- **frontend:** update favicon ([886389a](https://github.com/sct/overseerr/commit/886389a361da54c616da3bdfeee9a85e9d12bcf3)) +- notification framework ([d8e542e](https://github.com/sct/overseerr/commit/d8e542e5fe2ed76dcb20fb6dfc5f59430cd4245d)) +- notifications for media_available and media_approved ([a6c5e65](https://github.com/sct/overseerr/commit/a6c5e65bbfc196545471e99fe2e5b7194f9dd387)) +- rotten tomatoes scores on movie/tv details pages ([1694f60](https://github.com/sct/overseerr/commit/1694f60e8aa475ceeb7f170a783ec0ba70bd4bce)) +- upcoming movies on discover ([67290dd](https://github.com/sct/overseerr/commit/67290dd502571a22dcf8559ac07f42e855275bd0)) +- upcoming/trending list views and larger title cards ([94eaaf9](https://github.com/sct/overseerr/commit/94eaaf96b4302a832c52ccb72009b3593452c779)) +- upgrade tailwindcss to 2.0.1 ([fb5c791](https://github.com/sct/overseerr/commit/fb5c791b0b6b7593a472bf01713999a001f92dc7)) +- user edit functionality (managing permissions) ([185ac26](https://github.com/sct/overseerr/commit/185ac2648fd21c4bf9692ac5ac055e9c740065ca)) +- **api:** plex tv sync and recently added sync ([1390cc1](https://github.com/sct/overseerr/commit/1390cc1f130bb3975996e84b12ac833f55f2f753)) +- **frontend:** allow permission check for showing nav items ([0b239f0](https://github.com/sct/overseerr/commit/0b239f0bdfb1394897bce5c50b0d112abfbb4ad7)) +- **frontend:** alpha notice ([33da7e9](https://github.com/sct/overseerr/commit/33da7e9df3a2546b0f208bd3b1d1f268e343cead)) +- **frontend:** buttonWithDropdown component added (no hookups yet) ([4975841](https://github.com/sct/overseerr/commit/4975841b5d4ba4ed1ba8cacaa5a063eeb3b8c311)) +- **frontend:** cancel movie request modal ([1f9cbbf](https://github.com/sct/overseerr/commit/1f9cbbfdf1ac98e54de5b8777c52c7bfc69c7e20)) +- **frontend:** improved settings menu design for mobile ([16221a4](https://github.com/sct/overseerr/commit/16221a46a7d57c77f53aa0186263aa27267d9863)) +- **frontend:** initial Settings design ([8742da0](https://github.com/sct/overseerr/commit/8742da0ebb92d2f78309a998de0f67e788e14376)) +- **frontend:** plex library scan ([1bc3f7b](https://github.com/sct/overseerr/commit/1bc3f7be4b07211563a1e254c28ce51e1bc337a2)) +- **frontend:** plex settings page ([47714b6](https://github.com/sct/overseerr/commit/47714b698cf4351c1ee38bdf0b672d9f0baed03a)) +- **frontend:** radarr delete modal ([877a518](https://github.com/sct/overseerr/commit/877a5184158fb4aa371fa2ea2107032543c9aa37)) +- **frontend:** recently added on discover ([06dc606](https://github.com/sct/overseerr/commit/06dc606bcfeb50b7be1c35ac180c10738bade458)) +- **frontend:** slideover initial work ([14b9cb6](https://github.com/sct/overseerr/commit/14b9cb610c0dcfef939ebec328f371e1cdfb689d)) +- tv request modal status hookup ([5f8114f](https://github.com/sct/overseerr/commit/5f8114f730b067eb710704952824057e7b5b8fbf)) +- **.editorconfig:** add .editorconfig ([b982066](https://github.com/sct/overseerr/commit/b982066327525156f8dd0d32818d3fe7cb28f9c8)) +- **api:** add external ids to movie/tv response ([4aa7431](https://github.com/sct/overseerr/commit/4aa74319e0adcc19041239e57a00bc40fb127826)) +- **api:** add movie details endpoint ([b176148](https://github.com/sct/overseerr/commit/b1761484cb2861329763d51a868f37dd3098760d)) +- **api:** add tmdb discover api wrapper ([#67](https://github.com/sct/overseerr/issues/67)) ([839448f](https://github.com/sct/overseerr/commit/839448fcc8cc14ea83092af82e2ba3d0d92c9b73)) +- **api:** allow plex logins from users who have access to the server ([5147140](https://github.com/sct/overseerr/commit/514714071dfe4be04e607fe6412f5b3f0ef74dd4)) +- **api:** decouple media requests from media info ([8577db1](https://github.com/sct/overseerr/commit/8577db1be16f099d92c6649bbfb15f15e09a2f73)) +- **api:** discover endpoint for movie/tv ([#73](https://github.com/sct/overseerr/issues/73)) ([258bb93](https://github.com/sct/overseerr/commit/258bb93be2acc2ca32eaaefb617a5c326c5943ba)) +- **api:** initial implementation of the auth system ([#30](https://github.com/sct/overseerr/issues/30)) ([5343f35](https://github.com/sct/overseerr/commit/5343f35e5b572fe366a8712b24bd735de30e6170)) +- **api:** plex Sync (Movies) ([1be8b18](https://github.com/sct/overseerr/commit/1be8b183617c3a44ab8d4454a64b43dfe1d877fe)) +- **api:** public settings route ([#57](https://github.com/sct/overseerr/issues/57)) ([c0166e7](https://github.com/sct/overseerr/commit/c0166e7ecb5df110a4167f33338ed6406bf47f41)) +- **api:** radarr api wrapper / send to radarr when requests approved ([#93](https://github.com/sct/overseerr/issues/93)) ([48d62c3](https://github.com/sct/overseerr/commit/48d62c3178488d0d51831155ddd35cc31867db2b)) +- **api:** request api ([#80](https://github.com/sct/overseerr/issues/80)) ([f4c2c47](https://github.com/sct/overseerr/commit/f4c2c47e569e7faea7f99664966cb98b321ce952)) +- **api:** tmdb api wrapper / multi search route ([#62](https://github.com/sct/overseerr/issues/62)) ([c702c17](https://github.com/sct/overseerr/commit/c702c17cee00a52b23f685206e2d5d0c2eddf5a2)) +- **api:** tmdb trending api wrapper ([#68](https://github.com/sct/overseerr/issues/68)) ([ba34e54](https://github.com/sct/overseerr/commit/ba34e54d77d142d211df58d6ce9f53b6e673e004)) +- **api:** tv details endpoint ([a3beeed](https://github.com/sct/overseerr/commit/a3beeede7e72e99c7595673a27e38611ca4bb0cd)) +- **api:** validate plex when settings are saved ([8f6247d](https://github.com/sct/overseerr/commit/8f6247d82160704a3cfb76262696957b27641e87)) +- **api-user:** add basic User Entity and basic routing to fetch all users ([d902ef7](https://github.com/sct/overseerr/commit/d902ef72770712f2f71f33c09bca9ba99a30fc64)) +- **components/plexloginbutton:** added PlexLoginButton ([0abf743](https://github.com/sct/overseerr/commit/0abf743b17c664b58da18bdbf176f4a55ddc4179)) +- **extensions.json:** added recommended extensions for VSCode ([5dc9b51](https://github.com/sct/overseerr/commit/5dc9b510b8049516ad889c9d76a2f84daa0d2718)) +- **frontend:** add cancel request modal for titlecards ([f22f8c5](https://github.com/sct/overseerr/commit/f22f8c5d734be5cc0b1dcca869458a7321cd43a2)) +- **frontend:** approve/decline request well added to movie detail ([8f21358](https://github.com/sct/overseerr/commit/8f21358f797ed55923d90ba43acf1126856e9dfd)) +- **frontend:** basic discover page (only movies) ([#74](https://github.com/sct/overseerr/issues/74)) ([bbfe349](https://github.com/sct/overseerr/commit/bbfe349b52d308620796b37aaf986a0ed1ff0006)) +- **frontend:** design updates for responsive titlecards ([31809d9](https://github.com/sct/overseerr/commit/31809d952c8bafde3f63e2c1d952cc013149940e)) +- **frontend:** discover tv/movies full page ([be0003a](https://github.com/sct/overseerr/commit/be0003a85dc4e91799e85019aeb1110bd524a026)) +- **frontend:** initial search functionality ([#78](https://github.com/sct/overseerr/issues/78)) ([342d1a3](https://github.com/sct/overseerr/commit/342d1a3c75b32b172a51ca7d82fdfde8510abedf)) +- **frontend:** loading spinner ([de84658](https://github.com/sct/overseerr/commit/de84658b48985e24b0f92a1690387f6d59d0bc16)) +- **frontend:** logo updates ([5a43ec5](https://github.com/sct/overseerr/commit/5a43ec5405855deb244e8085484a9d2b743caba6)) +- **frontend:** modal component and basic request hookup ([#91](https://github.com/sct/overseerr/issues/91)) ([626099a](https://github.com/sct/overseerr/commit/626099a2c98fb30d0cb53d8ccf79a6bf75a00059)) +- **frontend:** new dashboard concept ([#82](https://github.com/sct/overseerr/issues/82)) ([eae38bb](https://github.com/sct/overseerr/commit/eae38bb9ec8588856f319387d2f262d7ee3f7e9c)) +- **frontend:** refresh indicator for titlecards / toasts ([4638fae](https://github.com/sct/overseerr/commit/4638fae336edc62a539796b3f55277a238683603)) +- **frontend:** request card / recent requests ([371e433](https://github.com/sct/overseerr/commit/371e43356d2c057e52368c32ffe2af1744311d91)) +- **frontend:** title detail (movie) initial version ([73ce24a](https://github.com/sct/overseerr/commit/73ce24a37bda3713e8cedc44e1ed065bdbc4ee4f)) +- **frontend/api:** beginning of new request modal ([2bf7e10](https://github.com/sct/overseerr/commit/2bf7e10e32718b36799be2feb0a7f9ff54d85744)) +- **frontend/api:** cast included with movie request and cast list on detail page ([04252f8](https://github.com/sct/overseerr/commit/04252f88bbdf51949923586feda582f86ac668ce)) +- **frontend/api:** i18n support ([9131254](https://github.com/sct/overseerr/commit/9131254f3371f12a17de44b6fa8f9bfb0e5c002e)) +- **frontend/api:** movie recommendations/similar request and frontend detail page update ([6398e36](https://github.com/sct/overseerr/commit/6398e3645a1e4ddbb9de9f4fda0a0659b4cac4d0)) +- **frontend/api:** tv details page ([02cbb5b](https://github.com/sct/overseerr/commit/02cbb5b030a3af5d62ab6c4cafdd4d800b4f61f4)) +- **frontend/api:** tv request modal (no status. only request) ([608b966](https://github.com/sct/overseerr/commit/608b96600a926adf16331b36e77789afa5d67069)) +- logout route/sign out button ([#54](https://github.com/sct/overseerr/issues/54)) ([cb9098f](https://github.com/sct/overseerr/commit/cb9098f457f79b71734959fd924b6c72ca77d61d)) +- user avatars from plex ([#53](https://github.com/sct/overseerr/issues/53)) ([e6349c1](https://github.com/sct/overseerr/commit/e6349c13a0eb0489289aa7663fcc64fa7d2906e6)) +- **layout:** created Layout component ([1f497e8](https://github.com/sct/overseerr/commit/1f497e8913146ceb9748d667e638141b2ca4612a)) +- **login component/route:** add: Login Component and Route ([6e47be2](https://github.com/sct/overseerr/commit/6e47be2fa865bcd51582ce30ebee6fd820c5f9dd)) +- **login route conditional:** on login route, do not display layout ([7d179ae](https://github.com/sct/overseerr/commit/7d179ae3b42d8ffae5e1b6e266038793260f1bbe)) +- **pass pageprops to loginpage:** pass page props to loginPage ([1597188](https://github.com/sct/overseerr/commit/159718891fb363001c650ac8b7e1446a1520ce4a)) +- **plex/utils:** added Plex OAuth class ([72f9624](https://github.com/sct/overseerr/commit/72f9624f1db721fe0324b7be9f0f811d2ae02389)) +- bootstrap the basic app structure ([89a6017](https://github.com/sct/overseerr/commit/89a6017c7f6f7637fe249ac0d667a652f44e02bb)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9e16f9eab..a016f6d46 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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). Translation status diff --git a/Dockerfile b/Dockerfile index cb80274ee..8f3ed32c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 ./ diff --git a/Dockerfile.local b/Dockerfile.local index b0b922e1f..f0228b6b9 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -1,4 +1,4 @@ -FROM node:14.17-alpine +FROM node:16.14-alpine COPY . /app WORKDIR /app diff --git a/README.md b/README.md index 75886ac72..f1c0a5cc0 100644 --- a/README.md +++ b/README.md @@ -3,49 +3,57 @@

Discord +Docker pulls +GitHub

-**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 + + + ## 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). - - +## 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. diff --git a/docs/README.md b/docs/README.md index a55645308..4a2c846a4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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). diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 2b309dccb..a10d7e713 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -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) diff --git a/docs/extending-overseerr/reverse-proxy.md b/docs/extending-overseerr/reverse-proxy.md index 1ebb4b469..5aa6fd462 100644 --- a/docs/extending-overseerr/reverse-proxy.md +++ b/docs/extending-overseerr/reverse-proxy.md @@ -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'; } ``` diff --git a/docs/extending-overseerr/third-party.md b/docs/extending-overseerr/third-party.md index c7d57fd4f..7ff2bcabf 100644 --- a/docs/extending-overseerr/third-party.md +++ b/docs/extending-overseerr/third-party.md @@ -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 diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 205aa99f7..957c9a51b 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -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 %} diff --git a/docs/support/faq.md b/docs/support/faq.md index 2add67a41..56a170941 100644 --- a/docs/support/faq.md +++ b/docs/support/faq.md @@ -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 → 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 → Services**. +Language profile support for Sonarr was added in [v1.20.0](https://github.com/fallenbagel/jellyseerr/releases/tag/v1.20.0) along with a new, _required_ **Language Profile** setting. If series requests are failing, make sure that you have a default language profile configured for each of your Sonarr servers in **Settings → Services**. ## Notifications ### 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. diff --git a/docs/support/need-help.md b/docs/support/need-help.md index 8e2cc8567..a2f77e677 100644 --- a/docs/support/need-help.md +++ b/docs/support/need-help.md @@ -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 `/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). diff --git a/docs/using-overseerr/notifications/README.md b/docs/using-overseerr/notifications/README.md index c894b0b2e..2bff13886 100644 --- a/docs/using-overseerr/notifications/README.md +++ b/docs/using-overseerr/notifications/README.md @@ -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) diff --git a/docs/using-overseerr/notifications/gotify.md b/docs/using-overseerr/notifications/gotify.md new file mode 100644 index 000000000..16e7cd599 --- /dev/null +++ b/docs/using-overseerr/notifications/gotify.md @@ -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 %} diff --git a/docs/using-overseerr/notifications/pushbullet.md b/docs/using-overseerr/notifications/pushbullet.md index 45edcc3a0..6e9be9c21 100644 --- a/docs/using-overseerr/notifications/pushbullet.md +++ b/docs/using-overseerr/notifications/pushbullet.md @@ -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. diff --git a/docs/using-overseerr/notifications/pushover.md b/docs/using-overseerr/notifications/pushover.md index 55893dbad..9554dc453 100644 --- a/docs/using-overseerr/notifications/pushover.md +++ b/docs/using-overseerr/notifications/pushover.md @@ -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). diff --git a/docs/using-overseerr/notifications/telegram.md b/docs/using-overseerr/notifications/telegram.md index d0e6f6fcb..9bdb96dbc 100644 --- a/docs/using-overseerr/notifications/telegram.md +++ b/docs/using-overseerr/notifications/telegram.md @@ -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 diff --git a/docs/using-overseerr/notifications/webhooks.md b/docs/using-overseerr/notifications/webhooks.md index c16374808..37a5c0486 100644 --- a/docs/using-overseerr/notifications/webhooks.md +++ b/docs/using-overseerr/notifications/webhooks.md @@ -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) | diff --git a/docs/using-overseerr/users/README.md b/docs/using-overseerr/users/README.md index 275e469c0..139e935a9 100644 --- a/docs/using-overseerr/users/README.md +++ b/docs/using-overseerr/users/README.md @@ -8,9 +8,9 @@ The user account created during Overseerr setup is the "Owner" account, which ca There are currently two methods to add users to Overseerr: importing Plex users and creating "local users." All new users are created with the [default permissions](../settings/README.md#default-permissions) defined in **Settings → Users**. -### 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. diff --git a/next-env.d.ts b/next-env.d.ts index 9bc3dd46b..4f11a03dc 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,5 +1,4 @@ /// -/// /// // NOTE: This file should not be edited diff --git a/overseerr-api.yml b/overseerr-api.yml index afb9922ec..cd7034b09 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -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 @@ -2461,23 +2619,36 @@ paths: content: application/json: schema: - type: object - properties: - id: - type: string - example: job-name - type: - type: string - enum: [process, command] - name: - type: string - example: A Job Name - nextExecutionTime: - type: string - example: '2020-09-02T05:02:23.000Z' - running: - type: boolean - example: false + $ref: '#/components/schemas/Job' + /settings/jobs/{jobId}/schedule: + post: + summary: Modify job schedule + description: Re-registers the job with the schedule specified. Will return the job in JSON format. + tags: + - settings + parameters: + - in: path + name: jobId + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + schedule: + type: string + example: '0 */5 * * * *' + responses: + '200': + description: Rescheduled job + content: + application/json: + schema: + $ref: '#/components/schemas/Job' /settings/cache: get: summary: Get a list of active caches @@ -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: [] diff --git a/package.json b/package.json index 433d3206b..8f6bfcebf 100644 --- a/package.json +++ b/package.json @@ -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" ] }, diff --git a/public/images/rotate1.jpg b/public/images/rotate1.jpg deleted file mode 100644 index 8d04487ea..000000000 Binary files a/public/images/rotate1.jpg and /dev/null differ diff --git a/public/images/rotate2.jpg b/public/images/rotate2.jpg deleted file mode 100644 index b819b38ff..000000000 Binary files a/public/images/rotate2.jpg and /dev/null differ diff --git a/public/images/rotate3.jpg b/public/images/rotate3.jpg deleted file mode 100644 index a8b181bac..000000000 Binary files a/public/images/rotate3.jpg and /dev/null differ diff --git a/public/images/rotate4.jpg b/public/images/rotate4.jpg deleted file mode 100644 index bdcbe8088..000000000 Binary files a/public/images/rotate4.jpg and /dev/null differ diff --git a/public/images/rotate5.jpg b/public/images/rotate5.jpg deleted file mode 100644 index d144c2ca7..000000000 Binary files a/public/images/rotate5.jpg and /dev/null differ diff --git a/public/images/rotate6.jpg b/public/images/rotate6.jpg deleted file mode 100644 index da6d1f94d..000000000 Binary files a/public/images/rotate6.jpg and /dev/null differ diff --git a/public/offline.html b/public/offline.html index 12c6c29f5..732782ee1 100644 --- a/public/offline.html +++ b/public/offline.html @@ -4,6 +4,7 @@ + You are offline diff --git a/public/sw.js b/public/sw.js index a3c816e8f..e04d229e5 100644 --- a/public/sw.js +++ b/public/sw.js @@ -90,8 +90,8 @@ self.addEventListener('push', (event) => { if (payload.actionUrl){ options.actions.push( { - action: 'viewmedia', - title: 'View Media', + action: 'view', + title: payload.actionUrlTitle ?? 'View', } ); } @@ -119,21 +119,17 @@ self.addEventListener('notificationclick', (event) => { event.notification.close(); - if (event.action === 'viewmedia') { - clients.openWindow(notificationData.actionUrl); - } else if (event.action === 'approve') { + if (event.action === 'approve') { fetch(`/api/v1/request/${notificationData.requestId}/approve`, { method: 'POST', }); - - clients.openWindow(notificationData.actionUrl); } else if (event.action === 'decline') { fetch(`/api/v1/request/${notificationData.requestId}/decline`, { method: 'POST', }); - - clients.openWindow(notificationData.actionUrl); - } else if (notificationData.actionUrl) { + } + + if (notificationData.actionUrl) { clients.openWindow(notificationData.actionUrl); } }, false); diff --git a/server/api/github.ts b/server/api/github.ts index a60a3341b..a2a71b41f 100644 --- a/server/api/github.ts +++ b/server/api/github.ts @@ -83,7 +83,7 @@ class GithubAPI extends ExternalAPI { } = {}): Promise { try { const data = await this.get( - '/repos/Fallenbagel/jellyseerr/releases', + '/repos/fallenbagel/jellyseerr/releases', { params: { per_page: take, @@ -110,7 +110,7 @@ class GithubAPI extends ExternalAPI { } = {}): Promise { try { const data = await this.get( - '/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 []; diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index 3bca32435..f6f6cb9db 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -15,6 +15,10 @@ export interface JellyfinLoginResponse { AccessToken: string; } +export interface JellyfinUserListResponse { + users: Array; +} + 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 { try { const account = await this.axios.get( - `/System/Info/Public'}` + "/System/Info/Public'}" ); return account.data.ServerName; } catch (e) { @@ -134,6 +138,19 @@ class JellyfinAPI { } } + public async getUsers(): Promise { + 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 { try { const account = await this.axios.get( diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index 3e8dedfc1..73278387a 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -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,28 +146,40 @@ class PlexAPI { public async syncLibraries(): Promise { const settings = getSettings(); - const libraries = await this.getLibraries(); + try { + const libraries = await this.getLibraries(); - const newLibraries: Library[] = libraries - // Remove libraries that are not movie or show - .filter((library) => library.type === 'movie' || library.type === 'show') - // Remove libraries that do not have a metadata agent set (usually personal video libraries) - .filter((library) => library.agent !== 'com.plexapp.agents.none') - .map((library) => { - const existing = settings.plex.libraries.find( - (l) => l.id === library.key && l.name === library.title - ); + const newLibraries: Library[] = libraries + // Remove libraries that are not movie or show + .filter( + (library) => library.type === 'movie' || library.type === 'show' + ) + // Remove libraries that do not have a metadata agent set (usually personal video libraries) + .filter((library) => library.agent !== 'com.plexapp.agents.none') + .map((library) => { + const existing = settings.plex.libraries.find( + (l) => l.id === library.key && l.name === library.title + ); - return { - id: library.key, - name: library.title, - enabled: existing?.enabled ?? false, - type: library.type, - lastScan: existing?.lastScan, - }; + return { + id: library.key, + name: library.title, + enabled: existing?.enabled ?? false, + type: library.type, + lastScan: existing?.lastScan, + }; + }); + + settings.plex.libraries = newLibraries; + } catch (e) { + logger.error('Failed to fetch Plex libraries', { + label: 'Plex API', + message: e.message, }); - settings.plex.libraries = newLibraries; + settings.plex.libraries = []; + } + settings.save(); } diff --git a/server/api/plextv.ts b/server/api/plextv.ts index 9efcecc2b..1733a85a6 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -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( diff --git a/server/api/servarr/base.ts b/server/api/servarr/base.ts index 75f138b57..9e4559339 100644 --- a/server/api/servarr/base.ts +++ b/server/api/servarr/base.ts @@ -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 extends ExternalAPI { this.apiName = apiName; } + public getSystemStatus = async (): Promise => { + try { + const response = await this.axios.get('/system/status'); + + return response.data; + } catch (e) { + throw new Error( + `[${this.apiName}] Failed to retrieve system status: ${e.message}` + ); + } + }; + public getProfiles = async (): Promise => { try { const data = await this.getRolling( diff --git a/server/api/servarr/radarr.ts b/server/api/servarr/radarr.ts index 0e0a41f18..7305baf09 100644 --- a/server/api/servarr/radarr.ts +++ b/server/api/servarr/radarr.ts @@ -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', { diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index b6793ed3f..7440d2786 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -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); diff --git a/server/api/tautulli.ts b/server/api/tautulli.ts new file mode 100644 index 000000000..bb7f37235 --- /dev/null +++ b/server/api/tautulli.ts @@ -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 { + try { + return ( + await this.axios.get('/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 { + try { + return ( + await this.axios.get('/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 { + try { + return ( + await this.axios.get('/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 { + try { + if (!user.plexId) { + throw new Error('User does not have an associated Plex ID'); + } + + return ( + await this.axios.get('/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 { + 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('/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; diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index ddc180592..cf5e280c2 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -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 => { + try { + const data = await this.get('/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 => { + try { + const data = await this.get('/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 => { + }): Promise => { try { - const data = await this.get(`/person/${personId}`, { + const data = await this.get(`/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 { + }): Promise { 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}` ); } } diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts index bd3c2d8be..2282fe052 100644 --- a/server/api/themoviedb/interfaces.ts +++ b/server/api/themoviedb/interfaces.ts @@ -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; diff --git a/server/constants/issue.ts b/server/constants/issue.ts new file mode 100644 index 000000000..2c9dcb697 --- /dev/null +++ b/server/constants/issue.ts @@ -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', +}; diff --git a/server/entity/Issue.ts b/server/entity/Issue.ts new file mode 100644 index 000000000..d8e05c565 --- /dev/null +++ b/server/entity/Issue.ts @@ -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) { + Object.assign(this, init); + } +} + +export default Issue; diff --git a/server/entity/IssueComment.ts b/server/entity/IssueComment.ts new file mode 100644 index 000000000..e45216392 --- /dev/null +++ b/server/entity/IssueComment.ts @@ -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) { + Object.assign(this, init); + } +} + +export default IssueComment; diff --git a/server/entity/Media.ts b/server/entity/Media.ts index e1907a02e..e0cadeef4 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -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) { 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}`; } } } diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index a935b13f1..f7f821156 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -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.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, - } - ); - } 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, - } - ); - } + this.sendNotification( + media, + this.status === MediaRequestStatus.APPROVED + ? autoApproved + ? Notification.MEDIA_AUTO_APPROVED + : Notification.MEDIA_APPROVED + : Notification.MEDIA_DECLINED + ); } } @@ -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}`, { - label: 'Media Request', - }); + 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,20 +418,22 @@ export class MediaRequest { throw new Error('Media already available'); } + const radarrMovieOptions: RadarrMovieOptions = { + profileId: qualityProfile, + qualityProfileId: qualityProfile, + rootFolderPath: rootFolder, + minimumAvailability: radarrSettings.minimumAvailability, + title: movie.title, + tmdbId: movie.id, + year: Number(movie.release_date.slice(0, 4)), + monitored: true, + tags, + searchNow: !radarrSettings.preventSearch, + }; + // Run this asynchronously so we don't wait for it on the UI side radarr - .addMovie({ - profileId: qualityProfile, - qualityProfileId: qualityProfile, - rootFolderPath: rootFolder, - minimumAvailability: radarrSettings.minimumAvailability, - title: movie.title, - tmdbId: movie.id, - year: Number(movie.release_date.slice(0, 4)), - monitored: true, - tags, - searchNow: !radarrSettings.preventSearch, - }) + .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}`, { - label: 'Media Request', - }); + 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,25 +643,29 @@ export class MediaRequest { tags = this.tags; logger.info(`Request has override tags`, { label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, tagIds: tags, }); } + const sonarrSeriesOptions: AddSeriesOptions = { + profileId: qualityProfile, + languageProfileId: languageProfile, + rootFolderPath: rootFolder, + title: series.name, + tvdbid: tvdbId, + seasons: this.seasons.map((season) => season.seasonNumber), + seasonFolder: sonarrSettings.enableSeasonFolders, + seriesType, + tags, + monitored: true, + searchNow: !sonarrSettings.preventSearch, + }; + // Run this asynchronously so we don't wait for it on the UI side sonarr - .addSeries({ - profileId: qualityProfile, - languageProfileId: languageProfile, - rootFolderPath: rootFolder, - title: series.name, - tvdbid: tvdbId, - seasons: this.seasons.map((season) => season.seasonNumber), - seasonFolder: sonarrSettings.enableSeasonFolders, - seriesType, - tags, - monitored: true, - searchNow: !sonarrSettings.preventSearch, - }) + .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)})` - : '' - }`, - message: truncate(series.overview, { - length: 500, - separator: /\s/, - omission: '…', - }), - image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${series.poster_path}`, - media, - extra: [ - { - name: 'Seasons', - value: this.seasons - .map((season) => season.seasonNumber) - .join(', '), - }, - ], - request: this, - }); + this.sendNotification(media, Notification.MEDIA_FAILED); }); - 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', { + logger.info('Sent request to Sonarr', { label: 'Media Request', - errorMessage, + requestId: this.id, + mediaId: this.media.id, }); - throw new Error(errorMessage); + } catch (e) { + logger.error('Something went wrong sending request to Sonarr', { + label: 'Media Request', + errorMessage: e.message, + requestId: this.id, + mediaId: this.media.id, + }); + throw new Error(e.message); } } } + + private async sendNotification(media: Media, type: Notification) { + const tmdb = new TheMovieDb(); + + try { + const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series'; + let event: string | undefined; + let notifyAdmin = true; + + switch (type) { + case Notification.MEDIA_APPROVED: + event = `${this.is4k ? '4K ' : ''}${mediaType} Request Approved`; + notifyAdmin = false; + break; + case Notification.MEDIA_DECLINED: + event = `${this.is4k ? '4K ' : ''}${mediaType} Request Declined`; + notifyAdmin = false; + break; + case Notification.MEDIA_PENDING: + event = `New ${this.is4k ? '4K ' : ''}${mediaType} Request`; + break; + case Notification.MEDIA_AUTO_APPROVED: + event = `${ + this.is4k ? '4K ' : '' + }${mediaType} Request Automatically Approved`; + break; + case Notification.MEDIA_FAILED: + event = `${this.is4k ? '4K ' : ''}${mediaType} Request Failed`; + break; + } + + if (this.type === MediaType.MOVIE) { + const movie = await tmdb.getMovie({ movieId: media.tmdbId }); + notificationManager.sendNotification(type, { + media, + request: this, + notifyAdmin, + notifyUser: notifyAdmin ? undefined : this.requestedBy, + event, + subject: `${movie.title}${ + movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' + }`, + message: truncate(movie.overview, { + length: 500, + separator: /\s/, + omission: '…', + }), + image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, + }); + } else if (this.type === MediaType.TV) { + const tv = await tmdb.getTvShow({ tvId: media.tmdbId }); + notificationManager.sendNotification(type, { + media, + request: this, + notifyAdmin, + notifyUser: notifyAdmin ? undefined : this.requestedBy, + event, + subject: `${tv.name}${ + tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : '' + }`, + message: truncate(tv.overview, { + length: 500, + separator: /\s/, + omission: '…', + }), + image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`, + extra: [ + { + name: 'Requested Seasons', + value: this.seasons + .map((season) => season.seasonNumber) + .join(', '), + }, + ], + }); + } + } catch (e) { + logger.error('Something went wrong sending media notification(s)', { + label: 'Notifications', + errorMessage: e.message, + requestId: this.id, + mediaId: this.media.id, + }); + } + } } diff --git a/server/entity/User.ts b/server/entity/User.ts index 04fbf3753..157e7f24f 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -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 { diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index 02f391112..08397b12f 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -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; diff --git a/server/index.ts b/server/index.ts index c5c0dd6ca..c80530120 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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, diff --git a/server/interfaces/api/issueInterfaces.ts b/server/interfaces/api/issueInterfaces.ts new file mode 100644 index 000000000..bd17f1958 --- /dev/null +++ b/server/interfaces/api/issueInterfaces.ts @@ -0,0 +1,6 @@ +import Issue from '../../entity/Issue'; +import { PaginatedResponse } from './common'; + +export interface IssueResultsResponse extends PaginatedResponse { + results: Issue[]; +} diff --git a/server/interfaces/api/mediaInterfaces.ts b/server/interfaces/api/mediaInterfaces.ts index e530d2d2c..d17716d20 100644 --- a/server/interfaces/api/mediaInterfaces.ts +++ b/server/interfaces/api/mediaInterfaces.ts @@ -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; + }; +} diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 0bb24780e..c486a1b46 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -3,7 +3,7 @@ import type { PaginatedResponse } from './common'; export type LogMessage = { timestamp: string; level: string; - label: string; + label?: string; message: string; data?: Record; }; @@ -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 { diff --git a/server/interfaces/api/userInterfaces.ts b/server/interfaces/api/userInterfaces.ts index facacd54c..e5f564826 100644 --- a/server/interfaces/api/userInterfaces.ts +++ b/server/interfaces/api/userInterfaces.ts @@ -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; +} diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index 18e3c7aba..a3e132d65 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -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; diff --git a/server/job/jellyfinsync/index.ts b/server/job/jellyfinsync/index.ts index 9d806efb5..23843d924 100644 --- a/server/job/jellyfinsync/index.ts +++ b/server/job/jellyfinsync/index.ts @@ -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; diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 5aabd5036..181d540d3 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -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,72 +21,91 @@ interface ScheduledJob { export const scheduledJobs: ScheduledJob[] = []; export const startJobs = (): void => { - // 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 * * * *', () => { - logger.info('Starting scheduled job: Plex Recently Added Scan', { - label: 'Jobs', - }); - plexRecentScanner.run(); - }), - running: () => plexRecentScanner.status().running, - cancelFn: () => plexRecentScanner.cancel(), - }); + const jobs = getSettings().jobs; + const mediaServerType = getSettings().main.mediaServerType; - // Run full plex scan every 24 hours - scheduledJobs.push({ - id: 'plex-full-scan', - name: 'Plex Full Library Scan', - type: 'process', - job: schedule.scheduleJob('0 0 3 * * *', () => { - logger.info('Starting scheduled job: Plex Full Library Scan', { - label: 'Jobs', - }); - plexFullScanner.run(); - }), - running: () => plexFullScanner.status().running, - cancelFn: () => plexFullScanner.cancel(), - }); + 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', + 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(), + }); - // 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 * * * *', () => { - logger.info('Starting scheduled job: Jellyfin Recently Added Sync', { - label: 'Jobs', - }); - jobJellyfinRecentSync.run(); - }), - running: () => jobJellyfinRecentSync.status().running, - cancelFn: () => jobJellyfinRecentSync.cancel(), - }); + // Run full plex scan every 24 hours + scheduledJobs.push({ + id: 'plex-full-scan', + name: 'Plex Full Library Scan', + type: 'process', + interval: 'long', + job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => { + logger.info('Starting scheduled job: Plex Full Library Scan', { + label: 'Jobs', + }); + plexFullScanner.run(); + }), + running: () => plexFullScanner.status().running, + cancelFn: () => plexFullScanner.cancel(), + }); + } else if ( + mediaServerType === MediaServerType.JELLYFIN || + mediaServerType === MediaServerType.EMBY + ) { + // Run recently added jellyfin sync every 5 minutes + scheduledJobs.push({ + id: 'jellyfin-recently-added-sync', + name: 'Jellyfin Recently Added Sync', + type: 'process', + interval: 'long', + job: schedule.scheduleJob( + jobs['jellyfin-recently-added-sync'].schedule, + () => { + logger.info('Starting scheduled job: Jellyfin Recently Added Sync', { + label: 'Jobs', + }); + jobJellyfinRecentSync.run(); + } + ), + running: () => jobJellyfinRecentSync.status().running, + cancelFn: () => jobJellyfinRecentSync.cancel(), + }); - // Run full jellyfin sync every 24 hours - scheduledJobs.push({ - id: 'jellyfin-full-sync', - name: 'Jellyfin Full Library Sync', - type: 'process', - job: schedule.scheduleJob('0 0 3 * * *', () => { - logger.info('Starting scheduled job: Jellyfin Full Sync', { - label: 'Jobs', - }); - jobJellyfinFullSync.run(); - }), - running: () => jobJellyfinFullSync.status().running, - cancelFn: () => jobJellyfinFullSync.cancel(), - }); + // Run full jellyfin sync every 24 hours + scheduledJobs.push({ + id: 'jellyfin-full-sync', + name: 'Jellyfin Full Library Sync', + type: 'process', + interval: 'long', + job: schedule.scheduleJob(jobs['jellyfin-full-sync'].schedule, () => { + logger.info('Starting scheduled job: Jellyfin Full Sync', { + label: 'Jobs', + }); + jobJellyfinFullSync.run(); + }), + 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', }); diff --git a/server/lib/cache.ts b/server/lib/cache.ts index fa03783c8..7782a05a8 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -40,7 +40,7 @@ class Cache { class CacheManager { private availableCaches: Record = { - 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, }), diff --git a/server/lib/downloadtracker.ts b/server/lib/downloadtracker.ts index 33282285e..c62e189d8 100644 --- a/server/lib/downloadtracker.ts +++ b/server/lib/downloadtracker.ts @@ -76,23 +76,32 @@ class DownloadTracker { url: RadarrAPI.buildUrl(server, '/api/v3'), }); - const queueItems = await radarr.getQueue(); + try { + const queueItems = await radarr.getQueue(); - this.radarrServers[server.id] = queueItems.map((item) => ({ - externalId: item.movieId, - estimatedCompletionTime: new Date(item.estimatedCompletionTime), - mediaType: MediaType.MOVIE, - size: item.size, - sizeLeft: item.sizeleft, - status: item.status, - timeLeft: item.timeleft, - title: item.title, - })); + this.radarrServers[server.id] = queueItems.map((item) => ({ + externalId: item.movieId, + estimatedCompletionTime: new Date(item.estimatedCompletionTime), + mediaType: MediaType.MOVIE, + size: item.size, + sizeLeft: item.sizeleft, + status: item.status, + timeLeft: item.timeleft, + title: item.title, + })); - if (queueItems.length > 0) { - logger.debug( - `Found ${queueItems.length} item(s) in progress on Radarr server: ${server.name}`, - { label: 'Download Tracker' } + if (queueItems.length > 0) { + logger.debug( + `Found ${queueItems.length} item(s) in progress on Radarr server: ${server.name}`, + { label: 'Download Tracker' } + ); + } + } catch { + logger.error( + `Unable to get queue from Radarr server: ${server.name}`, + { + label: 'Download Tracker', + } ); } @@ -134,42 +143,51 @@ class DownloadTracker { ); }); - // Load downloads from Radarr servers + // Load downloads from Sonarr servers Promise.all( filteredServers.map(async (server) => { if (server.syncEnabled) { - const radarr = new SonarrAPI({ + const sonarr = new SonarrAPI({ apiKey: server.apiKey, url: SonarrAPI.buildUrl(server, '/api/v3'), }); - const queueItems = await radarr.getQueue(); + try { + const queueItems = await sonarr.getQueue(); - this.sonarrServers[server.id] = queueItems.map((item) => ({ - externalId: item.seriesId, - estimatedCompletionTime: new Date(item.estimatedCompletionTime), - mediaType: MediaType.TV, - size: item.size, - sizeLeft: item.sizeleft, - status: item.status, - timeLeft: item.timeleft, - title: item.title, - })); + this.sonarrServers[server.id] = queueItems.map((item) => ({ + externalId: item.seriesId, + estimatedCompletionTime: new Date(item.estimatedCompletionTime), + mediaType: MediaType.TV, + size: item.size, + sizeLeft: item.sizeleft, + status: item.status, + timeLeft: item.timeleft, + title: item.title, + })); - if (queueItems.length > 0) { - logger.debug( - `Found ${queueItems.length} item(s) in progress on Sonarr server: ${server.name}`, - { label: 'Download Tracker' } + if (queueItems.length > 0) { + logger.debug( + `Found ${queueItems.length} item(s) in progress on Sonarr server: ${server.name}`, + { label: 'Download Tracker' } + ); + } + } catch { + logger.error( + `Unable to get queue from Sonarr server: ${server.name}`, + { + label: 'Download Tracker', + } ); } // Duplicate this data to matching servers const matchingServers = settings.sonarr.filter( - (rs) => - rs.hostname === server.hostname && - rs.port === server.port && - rs.baseUrl === server.baseUrl && - rs.id !== server.id + (ss) => + ss.hostname === server.hostname && + ss.port === server.port && + ss.baseUrl === server.baseUrl && + ss.id !== server.id ); if (matchingServers.length > 0) { diff --git a/server/lib/email/openpgpEncrypt.ts b/server/lib/email/openpgpEncrypt.ts index 5189df831..c067a7d58 100644 --- a/server/lib/email/openpgpEncrypt.ts +++ b/server/lib/email/openpgpEncrypt.ts @@ -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,146 +37,164 @@ class PGPEncryptor extends Transform { // Actually do stuff _flush = async (callback: TransformCallback): Promise => { - // Reconstruct message as buffer const message = Buffer.concat(this._messageChunks, this._messageLength); - const validPublicKeys = await Promise.all( - this._encryptionKeys.map((armoredKey) => openpgp.readKey({ armoredKey })) - ); - let privateKey: openpgp.PrivateKey | undefined; - // Just return the message if there is no one to encrypt for - if (!validPublicKeys.length) { - this.push(message); - return callback(); - } + try { + // Reconstruct message as buffer + const validPublicKeys = await Promise.all( + this._encryptionKeys.map((armoredKey) => + openpgp.readKey({ armoredKey }) + ) + ); + let privateKey: openpgp.PrivateKey | undefined; - // Only sign the message if private key and password exist - if (this._signingKey && this._password) { - privateKey = await openpgp.readPrivateKey({ - armoredKey: this._signingKey, + // Just return the message if there is no one to encrypt for + if (!validPublicKeys.length) { + this.push(message); + return callback(); + } + + // Only sign the message if private key and password exist + if (this._signingKey && this._password) { + privateKey = await openpgp.decryptKey({ + privateKey: await openpgp.readPrivateKey({ + armoredKey: this._signingKey, + }), + passphrase: this._password, + }); + } + + const emailPartDelimiter = '\r\n\r\n'; + const messageParts = message.toString().split(emailPartDelimiter); + + /** + * In this loop original headers are split up into two parts, + * one for the email that is sent + * and one for the encrypted content + */ + const header = messageParts.shift() as string; + const emailHeaders: string[][] = []; + const contentHeaders: string[][] = []; + const linesInHeader = header.split('\r\n'); + let previousHeader: string[] = []; + for (let i = 0; i < linesInHeader.length; i++) { + const line = linesInHeader[i]; + /** + * If it is a multi-line header (current line starts with whitespace) + * or it's the first line in the iteration + * add the current line with previous header and move on + */ + if (/^\s/.test(line) || i === 0) { + previousHeader.push(line); + continue; + } + + /** + * This is done to prevent the last header + * from being missed + */ + if (i === linesInHeader.length - 1) { + previousHeader.push(line); + } + + /** + * We need to seperate the actual content headers + * so that we can add it as a header for the encrypted content + * So that the content will be displayed properly after decryption + */ + if ( + /^(content-type|content-transfer-encoding):/i.test(previousHeader[0]) + ) { + contentHeaders.push(previousHeader); + } else { + emailHeaders.push(previousHeader); + } + previousHeader = [line]; + } + + // Generate a new boundary for the email content + const boundary = 'nm_' + randomBytes(14).toString('hex'); + /** + * Concatenate everything into single strings + * and add pgp headers to the email headers + */ + const emailHeadersRaw = + emailHeaders.map((line) => line.join('\r\n')).join('\r\n') + + '\r\n' + + 'Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";' + + '\r\n' + + ' boundary="' + + boundary + + '"' + + '\r\n' + + 'Content-Description: OpenPGP encrypted message' + + '\r\n' + + 'Content-Transfer-Encoding: 7bit'; + const contentHeadersRaw = contentHeaders + .map((line) => line.join('\r\n')) + .join('\r\n'); + + const encryptedMessage = await openpgp.encrypt({ + message: await openpgp.createMessage({ + text: + contentHeadersRaw + + emailPartDelimiter + + messageParts.join(emailPartDelimiter), + }), + encryptionKeys: validPublicKeys, + signingKeys: privateKey, }); - await openpgp.decryptKey({ privateKey, passphrase: this._password }); + const body = + '--' + + boundary + + '\r\n' + + 'Content-Type: application/pgp-encrypted\r\n' + + 'Content-Transfer-Encoding: 7bit\r\n' + + '\r\n' + + 'Version: 1\r\n' + + '\r\n' + + '--' + + boundary + + '\r\n' + + 'Content-Type: application/octet-stream; name=encrypted.asc\r\n' + + 'Content-Disposition: inline; filename=encrypted.asc\r\n' + + 'Content-Transfer-Encoding: 7bit\r\n' + + '\r\n' + + encryptedMessage + + '\r\n--' + + boundary + + '--\r\n'; + + this.push(Buffer.from(emailHeadersRaw + emailPartDelimiter + body)); + callback(); + } catch (e) { + logger.error( + 'Something went wrong while encrypting email message with OpenPGP. Sending email without encryption', + { + label: 'Notifications', + errorMessage: e.message, + } + ); + + this.push(message); + callback(); } - - const emailPartDelimiter = '\r\n\r\n'; - const messageParts = message.toString().split(emailPartDelimiter); - - /** - * In this loop original headers are split up into two parts, - * one for the email that is sent - * and one for the encrypted content - */ - const header = messageParts.shift() as string; - const emailHeaders: string[][] = []; - const contentHeaders: string[][] = []; - const linesInHeader = header.split('\r\n'); - let previousHeader: string[] = []; - for (let i = 0; i < linesInHeader.length; i++) { - const line = linesInHeader[i]; - /** - * If it is a multi-line header (current line starts with whitespace) - * or it's the first line in the iteration - * add the current line with previous header and move on - */ - if (/^\s/.test(line) || i === 0) { - previousHeader.push(line); - continue; - } - - /** - * This is done to prevent the last header - * from being missed - */ - if (i === linesInHeader.length - 1) { - previousHeader.push(line); - } - - /** - * We need to seperate the actual content headers - * so that we can add it as a header for the encrypted content - * So that the content will be displayed properly after decryption - */ - if ( - /^(content-type|content-transfer-encoding):/i.test(previousHeader[0]) - ) { - contentHeaders.push(previousHeader); - } else { - emailHeaders.push(previousHeader); - } - previousHeader = [line]; - } - - // Generate a new boundary for the email content - const boundary = 'nm_' + randomBytes(14).toString('hex'); - /** - * Concatenate everything into single strings - * and add pgp headers to the email headers - */ - const emailHeadersRaw = - emailHeaders.map((line) => line.join('\r\n')).join('\r\n') + - '\r\n' + - 'Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";' + - '\r\n' + - ' boundary="' + - boundary + - '"' + - '\r\n' + - 'Content-Description: OpenPGP encrypted message' + - '\r\n' + - 'Content-Transfer-Encoding: 7bit'; - const contentHeadersRaw = contentHeaders - .map((line) => line.join('\r\n')) - .join('\r\n'); - - const encryptedMessage = await openpgp.encrypt({ - message: await openpgp.createMessage({ - text: - contentHeadersRaw + - emailPartDelimiter + - messageParts.join(emailPartDelimiter), - }), - encryptionKeys: validPublicKeys, - signingKeys: privateKey, - }); - - const body = - '--' + - boundary + - '\r\n' + - 'Content-Type: application/pgp-encrypted\r\n' + - 'Content-Transfer-Encoding: 7bit\r\n' + - '\r\n' + - 'Version: 1\r\n' + - '\r\n' + - '--' + - boundary + - '\r\n' + - 'Content-Type: application/octet-stream; name=encrypted.asc\r\n' + - 'Content-Disposition: inline; filename=encrypted.asc\r\n' + - 'Content-Transfer-Encoding: 7bit\r\n' + - '\r\n' + - encryptedMessage + - '\r\n--' + - boundary + - '--\r\n'; - - this.push(Buffer.from(emailHeadersRaw + emailPartDelimiter + body)); - callback(); }; } export const openpgpEncrypt = (options: EncryptorOptions) => { - 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, - encryptionKeys: options.encryptionKeys, - }) + () => + new PGPEncryptor({ + signingKey: options.signingKey, + password: options.password, + encryptionKeys: options.encryptionKeys, + }) ); setImmediate(callback); }; diff --git a/server/lib/notifications/agents/agent.ts b/server/lib/notifications/agents/agent.ts index 66c52a16e..edfa1262d 100644 --- a/server/lib/notifications/agents/agent.ts +++ b/server/lib/notifications/agents/agent.ts @@ -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 { diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index bce1a2815..321200350 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -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 - 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,56 +122,94 @@ class DiscordAgent value: payload.request.requestedBy.displayName, inline: true, }); + + let status = ''; + switch (type) { + case Notification.MEDIA_PENDING: + color = EmbedColors.ORANGE; + status = 'Pending Approval'; + break; + case Notification.MEDIA_APPROVED: + case Notification.MEDIA_AUTO_APPROVED: + color = EmbedColors.PURPLE; + status = 'Processing'; + break; + case Notification.MEDIA_AVAILABLE: + color = EmbedColors.GREEN; + status = 'Available'; + break; + case Notification.MEDIA_DECLINED: + color = EmbedColors.RED; + status = 'Declined'; + break; + case Notification.MEDIA_FAILED: + color = EmbedColors.RED; + status = 'Failed'; + break; + } + + if (status) { + fields.push({ + name: 'Request Status', + value: status, + inline: true, + }); + } + } else if (payload.comment) { + fields.push({ + name: `Comment from ${payload.comment.user.displayName}`, + value: payload.comment.message, + inline: false, + }); + } else if (payload.issue) { + fields.push( + { + name: 'Reported By', + value: payload.issue.createdBy.displayName, + inline: true, + }, + { + name: 'Issue Type', + value: IssueTypeName[payload.issue.issueType], + inline: true, + }, + { + name: 'Issue Status', + value: + payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved', + inline: true, + } + ); + + switch (type) { + case Notification.ISSUE_CREATED: + case Notification.ISSUE_REOPENED: + color = EmbedColors.RED; + break; + case Notification.ISSUE_COMMENT: + color = EmbedColors.ORANGE; + break; + case Notification.ISSUE_RESOLVED: + color = EmbedColors.GREEN; + break; + } } - switch (type) { - case Notification.MEDIA_PENDING: - color = EmbedColors.ORANGE; - fields.push({ - name: 'Status', - value: 'Pending Approval', - inline: true, - }); - break; - case Notification.MEDIA_APPROVED: - case Notification.MEDIA_AUTO_APPROVED: - color = EmbedColors.PURPLE; - fields.push({ - name: 'Status', - value: 'Processing', - inline: true, - }); - break; - case Notification.MEDIA_AVAILABLE: - color = EmbedColors.GREEN; - fields.push({ - name: 'Status', - value: 'Available', - inline: true, - }); - break; - case Notification.MEDIA_DECLINED: - color = EmbedColors.RED; - fields.push({ - name: 'Status', - value: 'Declined', - inline: true, - }); - break; - case Notification.MEDIA_FAILED: - color = EmbedColors.RED; - fields.push({ - name: 'Status', - value: 'Failed', - inline: true, - }); - break; + for (const extra of payload.extra ?? []) { + fields.push({ + name: extra.name, + value: extra.value, + inline: true, + }); } - const url = - settings.main.applicationUrl && payload.media - ? `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}` - : undefined; + const url = applicationUrl + ? payload.issue + ? `${applicationUrl}/issues/${payload.issue.id}` + : payload.media + ? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}` + : undefined + : undefined; return { title: payload.subject, @@ -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 (payload.notifyUser) { - // Mention user who submitted the request - if ( - payload.notifyUser.settings?.hasNotificationType( - NotificationAgentKey.DISCORD, - type - ) && - payload.notifyUser.settings?.discordId - ) { - content = `<@${payload.notifyUser.settings.discordId}>`; + if (settings.options.enableMentions) { + if (payload.notifyUser) { + if ( + payload.notifyUser.settings?.hasNotificationType( + NotificationAgentKey.DISCORD, + type + ) && + payload.notifyUser.settings.discordId + ) { + userMentions.push(`<@${payload.notifyUser.settings.discordId}>`); + } } - } else { - // Mention all users with the Manage Requests permission - const userRepository = getRepository(User); - const users = await userRepository.find(); - content = users - .filter( - (user) => - user.hasPermission(Permission.MANAGE_REQUESTS) && - user.settings?.hasNotificationType( - NotificationAgentKey.DISCORD, - type - ) && - user.settings?.discordId && - // Check if it's the user's own auto-approved request - (type !== Notification.MEDIA_AUTO_APPROVED || - user.id !== payload.request?.requestedBy.id) - ) - .map((user) => `<@${user.settings?.discordId}>`) - .join(' '); + if (payload.notifyAdmin) { + const userRepository = getRepository(User); + const users = await userRepository.find(); + + userMentions.push( + ...users + .filter( + (user) => + user.settings?.hasNotificationType( + NotificationAgentKey.DISCORD, + type + ) && + user.settings.discordId && + shouldSendAdminNotification(type, user, payload) + ) + .map((user) => `<@${user.settings?.discordId}>`) + ); + } } await axios.post(settings.options.webhookUrl, { - username: settings.options.botUsername, + username: settings.options.botUsername + ? settings.options.botUsername + : getSettings().main.applicationTitle, avatar_url: settings.options.botAvatarUrl, embeds: [this.buildEmbed(type, payload)], - content, + content: userMentions.join(' '), } as DiscordWebhookPayload); return true; } catch (e) { logger.error('Error sending Discord notification', { label: 'Notifications', - mentions: content, type: Notification[type], subject: payload.subject, errorMessage: e.message, diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index 895590028..a1dd7e4e4 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -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 - 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 { 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', diff --git a/server/lib/notifications/agents/gotify.ts b/server/lib/notifications/agents/gotify.ts new file mode 100644 index 000000000..ecd54ce75 --- /dev/null +++ b/server/lib/notifications/agents/gotify.ts @@ -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 + 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 { + 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; diff --git a/server/lib/notifications/agents/lunasea.ts b/server/lib/notifications/agents/lunasea.ts index cf1f8fe46..0269e2600 100644 --- a/server/lib/notifications/agents/lunasea.ts +++ b/server/lib/notifications/agents/lunasea.ts @@ -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 - 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, }; } diff --git a/server/lib/notifications/agents/pushbullet.ts b/server/lib/notifications/agents/pushbullet.ts index 474442e2c..b7bc1919f 100644 --- a/server/lib/notifications/agents/pushbullet.ts +++ b/server/lib/notifications/agents/pushbullet.ts @@ -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 - 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; + return true; } - private constructMessageDetails( + private getNotificationPayload( type: Notification, payload: NotificationPayload - ): { - title: string; - body: string; - } { - let messageTitle = ''; - let message = ''; + ): PushbulletPayload { + const title = payload.event + ? `${payload.event} - ${payload.subject}` + : payload.subject; + let body = payload.message ?? ''; - const title = payload.subject; - const plot = payload.message; - const username = payload.request?.requestedBy.displayName; + if (payload.request) { + body += `\n\nRequested By: ${payload.request.requestedBy.displayName}`; - switch (type) { - case Notification.MEDIA_PENDING: - messageTitle = `New ${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Request`; - message += `${title}`; - if (plot) { - message += `\n\n${plot}`; - } - message += `\n\nRequested By: ${username}`; - message += `\nStatus: Pending Approval`; - break; - case Notification.MEDIA_APPROVED: - messageTitle = `${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Request Approved`; - message += `${title}`; - if (plot) { - message += `\n\n${plot}`; - } - message += `\n\nRequested By: ${username}`; - message += `\nStatus: Processing`; - break; - case Notification.MEDIA_AUTO_APPROVED: - messageTitle = `${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Request Automatically Approved`; - message += `${title}`; - if (plot) { - message += `\n\n${plot}`; - } - message += `\n\nRequested By: ${username}`; - message += `\nStatus: Processing`; - break; - case Notification.MEDIA_AVAILABLE: - messageTitle = `${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Now Available`; - message += `${title}`; - if (plot) { - message += `\n\n${plot}`; - } - message += `\n\nRequested By: ${username}`; - message += `\nStatus: Available`; - break; - case Notification.MEDIA_DECLINED: - messageTitle = `${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Request Declined`; - message += `${title}`; - if (plot) { - message += `\n\n${plot}`; - } - message += `\n\nRequested By: ${username}`; - message += `\nStatus: Declined`; - break; - case Notification.MEDIA_FAILED: - messageTitle = `Failed ${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Request`; - message += `${title}`; - if (plot) { - message += `\n\n${plot}`; - } - message += `\n\nRequested By: ${username}`; - message += `\nStatus: Failed`; - break; - case Notification.TEST_NOTIFICATION: - messageTitle = 'Test Notification'; - message += `${plot}`; - break; + let status = ''; + switch (type) { + case Notification.MEDIA_PENDING: + status = 'Pending Approval'; + break; + case Notification.MEDIA_APPROVED: + case Notification.MEDIA_AUTO_APPROVED: + status = 'Processing'; + break; + case Notification.MEDIA_AVAILABLE: + status = 'Available'; + break; + case Notification.MEDIA_DECLINED: + status = 'Declined'; + break; + case Notification.MEDIA_FAILED: + status = 'Failed'; + break; + } + + if (status) { + body += `\nRequest Status: ${status}`; + } + } else if (payload.comment) { + body += `\n\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message}`; + } else if (payload.issue) { + body += `\n\nReported By: ${payload.issue.createdBy.displayName}`; + body += `\nIssue Type: ${IssueTypeName[payload.issue.issueType]}`; + body += `\nIssue Status: ${ + payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved' + }`; } for (const extra of payload.extra ?? []) { - message += `\n${extra.name}: ${extra.value}`; + body += `\n${extra.name}: ${extra.value}`; } return { - title: messageTitle, - body: message, + type: 'note', + title, + body, }; } @@ -135,46 +101,133 @@ class PushbulletAgent payload: NotificationPayload ): Promise { const settings = this.getSettings(); + const endpoint = 'https://api.pushbullet.com/v2/pushes'; + const notificationPayload = this.getNotificationPayload(type, payload); - if (!hasNotificationType(type, settings.types ?? 0)) { - return true; - } - - logger.debug('Sending Pushbullet notification', { - label: 'Notifications', - type: Notification[type], - subject: payload.subject, - }); - - try { - const { title, body } = this.constructMessageDetails(type, payload); - - await axios.post( - 'https://api.pushbullet.com/v2/pushes', - { - type: 'note', - title: title, - body: body, - } as PushbulletPayload, - { - headers: { - 'Access-Token': settings.options.accessToken, - }, - } - ); - - return true; - } catch (e) { - logger.error('Error sending Pushbullet notification', { + // Send system notification + if ( + hasNotificationType(type, settings.types ?? 0) && + settings.enabled && + settings.options.accessToken + ) { + logger.debug('Sending Pushbullet notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, - errorMessage: e.message, - response: e.response?.data, }); - return false; + try { + await axios.post( + endpoint, + { ...notificationPayload, channel_tag: settings.options.channelTag }, + { + headers: { + 'Access-Token': settings.options.accessToken, + }, + } + ); + } catch (e) { + logger.error('Error sending Pushbullet notification', { + label: 'Notifications', + type: Notification[type], + subject: payload.subject, + errorMessage: e.message, + response: e.response?.data, + }); + + return false; + } } + + if (payload.notifyUser) { + if ( + payload.notifyUser.settings?.hasNotificationType( + NotificationAgentKey.PUSHBULLET, + type + ) && + payload.notifyUser.settings?.pushbulletAccessToken && + payload.notifyUser.settings.pushbulletAccessToken !== + settings.options.accessToken + ) { + logger.debug('Sending Pushbullet notification', { + label: 'Notifications', + recipient: payload.notifyUser.displayName, + type: Notification[type], + subject: payload.subject, + }); + + try { + await axios.post(endpoint, notificationPayload, { + headers: { + 'Access-Token': payload.notifyUser.settings.pushbulletAccessToken, + }, + }); + } catch (e) { + logger.error('Error sending Pushbullet notification', { + label: 'Notifications', + recipient: payload.notifyUser.displayName, + type: Notification[type], + subject: payload.subject, + errorMessage: e.message, + response: e.response?.data, + }); + + return false; + } + } + } + + if (payload.notifyAdmin) { + const userRepository = getRepository(User); + const users = await userRepository.find(); + + await Promise.all( + users + .filter( + (user) => + user.settings?.hasNotificationType( + NotificationAgentKey.PUSHBULLET, + type + ) && shouldSendAdminNotification(type, user, payload) + ) + .map(async (user) => { + if ( + user.settings?.pushbulletAccessToken && + (settings.options.channelTag || + user.settings.pushbulletAccessToken !== + settings.options.accessToken) + ) { + logger.debug('Sending Pushbullet notification', { + label: 'Notifications', + recipient: user.displayName, + type: Notification[type], + subject: payload.subject, + }); + + try { + await axios.post(endpoint, notificationPayload, { + headers: { + 'Access-Token': user.settings.pushbulletAccessToken, + }, + }); + } catch (e) { + logger.error('Error sending Pushbullet notification', { + label: 'Notifications', + recipient: user.displayName, + type: Notification[type], + subject: payload.subject, + errorMessage: e.message, + response: e.response?.data, + }); + + return false; + } + } + }) + ); + } + + return true; } } diff --git a/server/lib/notifications/agents/pushover.ts b/server/lib/notifications/agents/pushover.ts index e190ff89f..f8364c3f2 100644 --- a/server/lib/notifications/agents/pushover.ts +++ b/server/lib/notifications/agents/pushover.ts @@ -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 - 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; + return true; } - private constructMessageDetails( + private getNotificationPayload( type: Notification, payload: NotificationPayload - ): { - title: string; - message: string; - url: string | undefined; - url_title: string | undefined; - priority: number; - } { - const settings = getSettings(); - let messageTitle = ''; - let message = ''; - let url: string | undefined; - let url_title: string | undefined; + ): Partial { + const { applicationUrl, applicationTitle } = getSettings().main; + + const title = payload.event ?? payload.subject; + let message = payload.event ? `${payload.subject}` : ''; let priority = 0; - const title = payload.subject; - const plot = payload.message; - const username = payload.request?.requestedBy.displayName; + if (payload.message) { + message += `${message ? '\n' : ''}${payload.message}`; + } - switch (type) { - case Notification.MEDIA_PENDING: - messageTitle = `New ${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Request`; - message += `${title}`; - if (plot) { - message += `\n${plot}`; - } - message += `\n\nRequested By\n${username}`; - message += `\n\nStatus\nPending Approval`; - break; - case Notification.MEDIA_APPROVED: - messageTitle = `${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Request Approved`; - message += `${title}`; - if (plot) { - message += `\n${plot}`; - } - message += `\n\nRequested By\n${username}`; - message += `\n\nStatus\nProcessing`; - break; - case Notification.MEDIA_AUTO_APPROVED: - messageTitle = `${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Request Automatically Approved`; - message += `${title}`; - if (plot) { - message += `\n${plot}`; - } - message += `\n\nRequested By\n${username}`; - message += `\n\nStatus\nProcessing`; - break; - case Notification.MEDIA_AVAILABLE: - messageTitle = `${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Now Available`; - message += `${title}`; - if (plot) { - message += `\n${plot}`; - } - message += `\n\nRequested By\n${username}`; - message += `\n\nStatus\nAvailable`; - break; - case Notification.MEDIA_DECLINED: - messageTitle = `${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Request Declined`; - message += `${title}`; - if (plot) { - message += `\n${plot}`; - } - message += `\n\nRequested By\n${username}`; - message += `\n\nStatus\nDeclined`; + 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'; + priority = 1; + break; + case Notification.MEDIA_FAILED: + status = 'Failed'; + priority = 1; + break; + } + + if (status) { + message += `\nRequest Status: ${status}`; + } + } else if (payload.comment) { + message += `\n\nComment from ${payload.comment.user.displayName}: ${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; - break; - case Notification.MEDIA_FAILED: - messageTitle = `Failed ${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Request`; - message += `${title}`; - if (plot) { - message += `\n${plot}`; - } - message += `\n\nRequested By\n${username}`; - message += `\n\nStatus\nFailed`; - priority = 1; - break; - case Notification.TEST_NOTIFICATION: - messageTitle = 'Test Notification'; - message += `${plot}`; - break; + } } 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) { - 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,50 +132,134 @@ class PushoverAgent payload: NotificationPayload ): Promise { const settings = this.getSettings(); + const endpoint = 'https://api.pushover.net/1/messages.json'; + const notificationPayload = this.getNotificationPayload(type, payload); - if (!hasNotificationType(type, settings.types ?? 0)) { - return true; - } - - logger.debug('Sending Pushover notification', { - label: 'Notifications', - type: Notification[type], - subject: payload.subject, - }); - try { - const endpoint = 'https://api.pushover.net/1/messages.json'; - - const { - title, - message, - url, - url_title, - priority, - } = this.constructMessageDetails(type, payload); - - await axios.post(endpoint, { - token: settings.options.accessToken, - user: settings.options.userToken, - title: title, - message: message, - url: url, - url_title: url_title, - priority: priority, - html: 1, - } as PushoverPayload); - - return true; - } catch (e) { - logger.error('Error sending Pushover notification', { + // Send system notification + if ( + hasNotificationType(type, settings.types ?? 0) && + settings.enabled && + settings.options.accessToken && + settings.options.userToken + ) { + logger.debug('Sending Pushover notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, - errorMessage: e.message, - response: e.response?.data, }); - return false; + try { + await axios.post(endpoint, { + ...notificationPayload, + token: settings.options.accessToken, + user: settings.options.userToken, + } as PushoverPayload); + } catch (e) { + logger.error('Error sending Pushover notification', { + label: 'Notifications', + type: Notification[type], + subject: payload.subject, + errorMessage: e.message, + response: e.response?.data, + }); + + return false; + } } + + if (payload.notifyUser) { + if ( + payload.notifyUser.settings?.hasNotificationType( + NotificationAgentKey.PUSHOVER, + type + ) && + payload.notifyUser.settings.pushoverApplicationToken && + payload.notifyUser.settings.pushoverUserKey && + (payload.notifyUser.settings.pushoverApplicationToken !== + settings.options.accessToken || + payload.notifyUser.settings.pushoverUserKey !== + settings.options.userToken) + ) { + logger.debug('Sending Pushover notification', { + label: 'Notifications', + recipient: payload.notifyUser.displayName, + type: Notification[type], + subject: payload.subject, + }); + + try { + await axios.post(endpoint, { + ...notificationPayload, + token: payload.notifyUser.settings.pushoverApplicationToken, + user: payload.notifyUser.settings.pushoverUserKey, + } as PushoverPayload); + } catch (e) { + logger.error('Error sending Pushover notification', { + label: 'Notifications', + recipient: payload.notifyUser.displayName, + type: Notification[type], + subject: payload.subject, + errorMessage: e.message, + response: e.response?.data, + }); + + return false; + } + } + } + + if (payload.notifyAdmin) { + const userRepository = getRepository(User); + const users = await userRepository.find(); + + await Promise.all( + users + .filter( + (user) => + user.settings?.hasNotificationType( + NotificationAgentKey.PUSHOVER, + type + ) && shouldSendAdminNotification(type, user, payload) + ) + .map(async (user) => { + if ( + user.settings?.pushoverApplicationToken && + user.settings?.pushoverUserKey && + user.settings.pushoverApplicationToken !== + settings.options.accessToken && + user.settings.pushoverUserKey !== settings.options.userToken + ) { + logger.debug('Sending Pushover notification', { + label: 'Notifications', + recipient: user.displayName, + type: Notification[type], + subject: payload.subject, + }); + + try { + await axios.post(endpoint, { + ...notificationPayload, + token: user.settings.pushoverApplicationToken, + user: user.settings.pushoverUserKey, + } as PushoverPayload); + } catch (e) { + logger.error('Error sending Pushover notification', { + label: 'Notifications', + recipient: user.displayName, + type: Notification[type], + subject: payload.subject, + errorMessage: e.message, + response: e.response?.data, + }); + + return false; + } + } + }) + ); + } + + return true; } } diff --git a/server/lib/notifications/agents/slack.ts b/server/lib/notifications/agents/slack.ts index 825460d5e..ca10c269c 100644 --- a/server/lib/notifications/agents/slack.ts +++ b/server/lib/notifications/agents/slack.ts @@ -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}`, }); - } - switch (type) { - case Notification.MEDIA_PENDING: - header = `New ${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Request`; + let status = ''; + switch (type) { + case Notification.MEDIA_PENDING: + status = 'Pending Approval'; + break; + case Notification.MEDIA_APPROVED: + case Notification.MEDIA_AUTO_APPROVED: + status = 'Processing'; + break; + case Notification.MEDIA_AVAILABLE: + status = 'Available'; + break; + case Notification.MEDIA_DECLINED: + status = 'Declined'; + break; + case Notification.MEDIA_FAILED: + status = 'Failed'; + break; + } + + if (status) { fields.push({ type: 'mrkdwn', - text: '*Status*\nPending Approval', + text: `*Request Status*\n${status}`, }); - break; - case Notification.MEDIA_APPROVED: - header = `${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Request Approved`; - fields.push({ + } + } else if (payload.comment) { + fields.push({ + type: 'mrkdwn', + text: `*Comment from ${payload.comment.user.displayName}*\n${payload.comment.message}`, + }); + } else if (payload.issue) { + fields.push( + { type: 'mrkdwn', - text: '*Status*\nProcessing', - }); - break; - case Notification.MEDIA_AUTO_APPROVED: - header = `${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Request Automatically Approved`; - fields.push({ + text: `*Reported By*\n${payload.issue.createdBy.displayName}`, + }, + { type: 'mrkdwn', - text: '*Status*\nProcessing', - }); - break; - case Notification.MEDIA_AVAILABLE: - header = `${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Now Available`; - fields.push({ + text: `*Issue Type*\n${IssueTypeName[payload.issue.issueType]}`, + }, + { type: 'mrkdwn', - text: '*Status*\nAvailable', - }); - break; - case Notification.MEDIA_DECLINED: - header = `${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Request Declined`; - fields.push({ - type: 'mrkdwn', - text: '*Status*\nDeclined', - }); - break; - case Notification.MEDIA_FAILED: - header = `Failed ${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Request`; - fields.push({ - type: 'mrkdwn', - text: '*Status*\nFailed', - }); - break; - case Notification.TEST_NOTIFICATION: - header = 'Test Notification'; - break; + text: `*Issue Status*\n${ + payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved' + }`, + } + ); } for (const extra of payload.extra ?? []) { @@ -139,30 +128,28 @@ class SlackAgent }); } - if (settings.main.applicationUrl && payload.media) { - actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`; - } + const blocks: EmbedBlock[] = []; - const blocks: EmbedBlock[] = [ - { - type: 'header', - text: { - type: 'plain_text', - text: header, - }, - }, - ]; - - if (type !== Notification.TEST_NOTIFICATION) { + if (payload.event) { blocks.push({ - type: 'section', - text: { - type: 'mrkdwn', - text: `*${payload.subject}*`, - }, + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `*${payload.event}*`, + }, + ], }); } + blocks.push({ + type: 'header', + text: { + type: 'plain_text', + text: payload.subject, + }, + }); + if (payload.message) { blocks.push({ type: 'section', @@ -183,30 +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, }; } diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts index c48f637b0..3450a3c2a 100644 --- a/server/lib/notifications/agents/telegram.ts +++ b/server/lib/notifications/agents/telegram.ts @@ -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 - 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 { + const { applicationUrl, applicationTitle } = getSettings().main; /* eslint-disable no-useless-escape */ - switch (type) { - case Notification.MEDIA_PENDING: - message += `\*New ${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Request\*`; - message += `\n\n\*${title}\*`; - if (plot) { - message += `\n${plot}`; - } - message += `\n\n\*Requested By\*\n${user}`; - message += `\n\n\*Status\*\nPending Approval`; - break; - case Notification.MEDIA_APPROVED: - message += `\*${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Request Approved\*`; - message += `\n\n\*${title}\*`; - if (plot) { - message += `\n${plot}`; - } - message += `\n\n\*Requested By\*\n${user}`; - message += `\n\n\*Status\*\nProcessing`; - break; - case Notification.MEDIA_AUTO_APPROVED: - message += `\*${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Request Automatically Approved\*`; - message += `\n\n\*${title}\*`; - if (plot) { - message += `\n${plot}`; - } - message += `\n\n\*Requested By\*\n${user}`; - message += `\n\n\*Status\*\nProcessing`; - break; - case Notification.MEDIA_AVAILABLE: - message += `\*${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Now Available\*`; - message += `\n\n\*${title}\*`; - if (plot) { - message += `\n${plot}`; - } - message += `\n\n\*Requested By\*\n${user}`; - message += `\n\n\*Status\*\nAvailable`; - break; - case Notification.MEDIA_DECLINED: - message += `\*${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Request Declined\*`; - message += `\n\n\*${title}\*`; - if (plot) { - message += `\n${plot}`; - } - message += `\n\n\*Requested By\*\n${user}`; - message += `\n\n\*Status\*\nDeclined`; - break; - case Notification.MEDIA_FAILED: - message += `\*Failed ${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Request\*`; - message += `\n\n\*${title}\*`; - if (plot) { - message += `\n${plot}`; - } - message += `\n\n\*Requested By\*\n${user}`; - message += `\n\n\*Status\*\nFailed`; - break; - case Notification.TEST_NOTIFICATION: - message += `\*Test Notification\*`; - message += `\n\n${plot}`; - break; + let message = `\*${this.escapeText( + payload.event ? `${payload.event} - ${payload.subject}` : payload.subject + )}\*`; + if (payload.message) { + message += `\n${this.escapeText(payload.message)}`; + } + + if (payload.request) { + message += `\n\n\*Requested By:\* ${this.escapeText( + payload.request?.requestedBy.displayName + )}`; + + let status = ''; + switch (type) { + case Notification.MEDIA_PENDING: + status = 'Pending Approval'; + break; + case Notification.MEDIA_APPROVED: + case Notification.MEDIA_AUTO_APPROVED: + status = 'Processing'; + break; + case Notification.MEDIA_AVAILABLE: + status = 'Available'; + break; + case Notification.MEDIA_DECLINED: + status = 'Declined'; + break; + case Notification.MEDIA_FAILED: + status = 'Failed'; + break; + } + + if (status) { + message += `\n\*Request Status:\* ${status}`; + } + } else if (payload.comment) { + message += `\n\n\*Comment from ${this.escapeText( + payload.comment.user.displayName + )}:\* ${this.escapeText(payload.comment.message)}`; + } else if (payload.issue) { + message += `\n\n\*Reported By:\* ${this.escapeText( + payload.issue.createdBy.displayName + )}`; + message += `\n\*Issue Type:\* ${IssueTypeName[payload.issue.issueType]}`; + message += `\n\*Issue Status:\* ${ + payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved' + }`; } for (const extra of payload.extra ?? []) { - message += `\n\n\*${extra.name}\*\n${extra.value}`; + message += `\n\*${extra.name}:\* ${extra.value}`; } - if (settings.main.applicationUrl && payload.media) { - const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`; - message += `\n\n\[Open in ${applicationTitle}\]\(${actionUrl}\)`; + const url = applicationUrl + ? payload.issue + ? `${applicationUrl}/issues/${payload.issue.id}` + : payload.media + ? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}` + : undefined + : undefined; + + if (url) { + message += `\n\n\[View ${ + payload.issue ? 'Issue' : 'Media' + } in ${this.escapeText(applicationTitle)}\]\(${url}\)`; } /* eslint-enable */ return payload.image - ? ({ + ? { photo: payload.image, caption: message, parse_mode: 'MarkdownV2', - chat_id: chatId, - disable_notification: !!sendSilently, - } as TelegramPhotoPayload) - : ({ + } + : { text: message, parse_mode: 'MarkdownV2', - chat_id: chatId, - disable_notification: !!sendSilently, - } as TelegramMessagePayload); + }; } public async send( @@ -179,13 +152,16 @@ class TelegramAgent payload: NotificationPayload ): Promise { 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', diff --git a/server/lib/notifications/agents/webhook.ts b/server/lib/notifications/agents/webhook.ts index f5d48dd2c..ba2bf5e59 100644 --- a/server/lib/notifications/agents/webhook.ts +++ b/server/lib/notifications/agents/webhook.ts @@ -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 = { notification_type: (_payload, type) => Notification[type], + event: 'event', subject: 'subject', message: 'message', image: 'image', @@ -22,13 +24,12 @@ const KeyMap: Record = { 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 = { 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 - 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') { diff --git a/server/lib/notifications/agents/webpush.ts b/server/lib/notifications/agents/webpush.ts index 18728813c..c87d9496c 100644 --- a/server/lib/notifications/agents/webpush.ts +++ b/server/lib/notifications/agents/webpush.ts @@ -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 - 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( diff --git a/server/lib/notifications/index.ts b/server/lib/notifications/index.ts index a2eb01419..b8111d02f 100644 --- a/server/lib/notifications/index.ts +++ b/server/lib/notifications/index.ts @@ -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[] = []; diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts index fbf36e6b8..95160d380 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -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 { diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts index 01710a1e5..f76ea92b0 100644 --- a/server/lib/scanners/baseScanner.ts +++ b/server/lib/scanners/baseScanner.ts @@ -146,9 +146,8 @@ class BaseScanner { existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] !== externalServiceId ) { - existing[ - is4k ? 'externalServiceId4k' : 'externalServiceId' - ] = externalServiceId; + existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] = + externalServiceId; changedExisting = true; } @@ -157,9 +156,8 @@ class BaseScanner { existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !== externalServiceSlug ) { - existing[ - is4k ? 'externalServiceSlug4k' : 'externalServiceSlug' - ] = externalServiceSlug; + existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = + externalServiceSlug; changedExisting = true; } @@ -390,15 +388,13 @@ class BaseScanner { } 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 diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts index 6c43dcf67..cd8dbd76a 100644 --- a/server/lib/scanners/plex/index.ts +++ b/server/lib/scanners/plex/index.ts @@ -31,7 +31,8 @@ type SyncStatus = StatusBase & { class PlexScanner extends BaseScanner - implements RunnableScanner { + implements RunnableScanner +{ 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); diff --git a/server/lib/scanners/radarr/index.ts b/server/lib/scanners/radarr/index.ts index 4c4e6e7fc..5f47b9d97 100644 --- a/server/lib/scanners/radarr/index.ts +++ b/server/lib/scanners/radarr/index.ts @@ -10,7 +10,8 @@ type SyncStatus = StatusBase & { class RadarrScanner extends BaseScanner - implements RunnableScanner { + implements RunnableScanner +{ private servers: RadarrSettings[]; private currentServer: RadarrSettings; private radarrApi: RadarrAPI; @@ -72,7 +73,7 @@ class RadarrScanner } private async processRadarrMovie(radarrMovie: RadarrMovie): Promise { - 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', { diff --git a/server/lib/scanners/sonarr/index.ts b/server/lib/scanners/sonarr/index.ts index 73500db9f..044f74ec7 100644 --- a/server/lib/scanners/sonarr/index.ts +++ b/server/lib/scanners/sonarr/index.ts @@ -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 - implements RunnableScanner { + implements RunnableScanner +{ 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) { diff --git a/server/lib/search.ts b/server/lib/search.ts new file mode 100644 index 000000000..c625f512d --- /dev/null +++ b/server/lib/search.ts @@ -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; +} + +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 + | PromiseFulfilledResult + | PromiseFulfilledResult + )[]; + + 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 + | PromiseFulfilledResult + )[]; + + 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, + }; + }, +}); diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 114625127..6b167d7c4 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -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; } 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 { + return this.data.jobs; + } + + set jobs(data: Record) { + this.data.jobs = data; + } + get clientId(): string { if (!this.data.clientId) { this.data.clientId = randomUUID(); diff --git a/server/logger.ts b/server/logger.ts index de053bef3..4f736e4ab 100644 --- a/server/logger.ts +++ b/server/logger.ts @@ -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() + ), }), ], }); diff --git a/server/migration/1608217312474-AddUserRequestDeleteCascades.ts b/server/migration/1608217312474-AddUserRequestDeleteCascades.ts index ce3de8499..e2aa88653 100644 --- a/server/migration/1608217312474-AddUserRequestDeleteCascades.ts +++ b/server/migration/1608217312474-AddUserRequestDeleteCascades.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserRequestDeleteCascades1608219049304 - implements MigrationInterface { + implements MigrationInterface +{ name = 'AddUserRequestDeleteCascades1608219049304'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1608477467935-AddLastSeasonChangeMedia.ts b/server/migration/1608477467935-AddLastSeasonChangeMedia.ts index 89bb43171..fba7af7f3 100644 --- a/server/migration/1608477467935-AddLastSeasonChangeMedia.ts +++ b/server/migration/1608477467935-AddLastSeasonChangeMedia.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class AddLastSeasonChangeMedia1608477467935 - implements MigrationInterface { + implements MigrationInterface +{ name = 'AddLastSeasonChangeMedia1608477467935'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1608477467936-ForceDropImdbUniqueConstraint.ts b/server/migration/1608477467936-ForceDropImdbUniqueConstraint.ts index 9cd006ec6..6a109e4d1 100644 --- a/server/migration/1608477467936-ForceDropImdbUniqueConstraint.ts +++ b/server/migration/1608477467936-ForceDropImdbUniqueConstraint.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class ForceDropImdbUniqueConstraint1608477467935 - implements MigrationInterface { + implements MigrationInterface +{ name = 'ForceDropImdbUniqueConstraint1608477467936'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1609236552057-RemoveTmdbIdUniqueConstraint.ts b/server/migration/1609236552057-RemoveTmdbIdUniqueConstraint.ts index 0be266993..2cd5415e7 100644 --- a/server/migration/1609236552057-RemoveTmdbIdUniqueConstraint.ts +++ b/server/migration/1609236552057-RemoveTmdbIdUniqueConstraint.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class RemoveTmdbIdUniqueConstraint1609236552057 - implements MigrationInterface { + implements MigrationInterface +{ name = 'RemoveTmdbIdUniqueConstraint1609236552057'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts b/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts index 78dbc06e2..25e42a74e 100644 --- a/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts +++ b/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class AddMediaAddedFieldToMedia1610522845513 - implements MigrationInterface { + implements MigrationInterface +{ name = 'AddMediaAddedFieldToMedia1610522845513'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts b/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts index 49f47e403..355384a05 100644 --- a/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts +++ b/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class SonarrRadarrSyncServiceFields1611757511674 - implements MigrationInterface { + implements MigrationInterface +{ name = 'SonarrRadarrSyncServiceFields1611757511674'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts b/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts index 01278c017..7d191d106 100644 --- a/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts +++ b/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class AddResetPasswordGuidAndExpiryDate1612482778137 - implements MigrationInterface { + implements MigrationInterface +{ name = 'AddResetPasswordGuidAndExpiryDate1612482778137'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1613379909641-AddJellyfinUserParams.ts b/server/migration/1613379909641-AddJellyfinUserParams.ts index d0663912d..46ef3319d 100644 --- a/server/migration/1613379909641-AddJellyfinUserParams.ts +++ b/server/migration/1613379909641-AddJellyfinUserParams.ts @@ -5,63 +5,63 @@ export class AddJellyfinUserParams1613379909641 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( - `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "jellyfinUsername" varchar, "jellyfinId" varchar, "jellyfinAuthToken" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + 'CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "jellyfinUsername" varchar, "jellyfinId" varchar, "jellyfinAuthToken" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))' ); await queryRunner.query( - `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate" FROM "user"` + 'INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate" FROM "user"' ); - await queryRunner.query(`DROP TABLE "user"`); - await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); - await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); - await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); - await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query('DROP TABLE "user"'); + await queryRunner.query('ALTER TABLE "temporary_user" RENAME TO "user"'); + await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"'); + await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"'); + await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"'); await queryRunner.query( - `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaID" varchar, "jellyfinMediaID4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + 'CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaID" varchar, "jellyfinMediaID4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))' ); await queryRunner.query( - `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "media"` + 'INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "media"' ); - await queryRunner.query(`DROP TABLE "media"`); - await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); + await queryRunner.query('DROP TABLE "media"'); + await queryRunner.query('ALTER TABLE "temporary_media" RENAME TO "media"'); await queryRunner.query( - `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + 'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ' ); await queryRunner.query( - `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + 'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ' ); await queryRunner.query( - `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + 'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ' ); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); - await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); - await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); - await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); + await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"'); + await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"'); + await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"'); + await queryRunner.query('ALTER TABLE "media" RENAME TO "temporary_media"'); await queryRunner.query( - `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + 'CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))' ); await queryRunner.query( - `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "temporary_media"` + 'INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "temporary_media"' ); - await queryRunner.query(`DROP TABLE "temporary_media"`); + await queryRunner.query('DROP TABLE "temporary_media"'); await queryRunner.query( - `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + 'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ' ); await queryRunner.query( - `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + 'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ' ); await queryRunner.query( - `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + 'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ' ); - await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); + await queryRunner.query('ALTER TABLE "user" RENAME TO "temporary_user"'); await queryRunner.query( - `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + 'CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))' ); await queryRunner.query( - `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate" FROM "temporary_user"` + 'INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate" FROM "temporary_user"' ); - await queryRunner.query(`DROP TABLE "temporary_user"`); + await queryRunner.query('DROP TABLE "temporary_user"'); } } diff --git a/server/migration/1613412948344-ServerTypeEnum.ts b/server/migration/1613412948344-ServerTypeEnum.ts index c819abd82..b8f950538 100644 --- a/server/migration/1613412948344-ServerTypeEnum.ts +++ b/server/migration/1613412948344-ServerTypeEnum.ts @@ -4,88 +4,88 @@ export class ServerTypeEnum1613412948344 implements MigrationInterface { name = 'ServerTypeEnum1613412948344'; public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); - await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); - await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"'); + await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"'); + await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"'); await queryRunner.query( - `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + 'CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))' ); await queryRunner.query( - `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "media"` + 'INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "media"' ); - await queryRunner.query(`DROP TABLE "media"`); - await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); + await queryRunner.query('DROP TABLE "media"'); + await queryRunner.query('ALTER TABLE "temporary_media" RENAME TO "media"'); await queryRunner.query( - `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + 'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ' ); await queryRunner.query( - `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + 'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ' ); await queryRunner.query( - `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + 'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ' ); - await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); - await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); - await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"'); + await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"'); + await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"'); await queryRunner.query( - `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaID" varchar, "jellyfinMediaID4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + 'CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaID" varchar, "jellyfinMediaID4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))' ); await queryRunner.query( - `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "media"` + 'INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "media"' ); - await queryRunner.query(`DROP TABLE "media"`); - await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); + await queryRunner.query('DROP TABLE "media"'); + await queryRunner.query('ALTER TABLE "temporary_media" RENAME TO "media"'); await queryRunner.query( - `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + 'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ' ); await queryRunner.query( - `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + 'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ' ); await queryRunner.query( - `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + 'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ' ); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); - await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); - await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); - await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); + await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"'); + await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"'); + await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"'); + await queryRunner.query('ALTER TABLE "media" RENAME TO "temporary_media"'); await queryRunner.query( - `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + 'CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))' ); await queryRunner.query( - `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "temporary_media"` + 'INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "temporary_media"' ); - await queryRunner.query(`DROP TABLE "temporary_media"`); + await queryRunner.query('DROP TABLE "temporary_media"'); await queryRunner.query( - `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + 'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ' ); await queryRunner.query( - `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + 'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ' ); await queryRunner.query( - `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + 'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ' ); - await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); - await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); - await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); - await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); + await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"'); + await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"'); + await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"'); + await queryRunner.query('ALTER TABLE "media" RENAME TO "temporary_media"'); await queryRunner.query( - `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + 'CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))' ); await queryRunner.query( - `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "temporary_media"` + 'INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "temporary_media"' ); - await queryRunner.query(`DROP TABLE "temporary_media"`); + await queryRunner.query('DROP TABLE "temporary_media"'); await queryRunner.query( - `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + 'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ' ); await queryRunner.query( - `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + 'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ' ); await queryRunner.query( - `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + 'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ' ); } } diff --git a/server/migration/1613670041760-AddJellyfinDeviceId.ts b/server/migration/1613670041760-AddJellyfinDeviceId.ts index cc79a0f19..104b4146b 100644 --- a/server/migration/1613670041760-AddJellyfinDeviceId.ts +++ b/server/migration/1613670041760-AddJellyfinDeviceId.ts @@ -5,119 +5,119 @@ export class AddJellyfinDeviceId1613670041760 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( - `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + 'CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))' ); await queryRunner.query( - `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken" FROM "user"` + 'INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken" FROM "user"' ); - await queryRunner.query(`DROP TABLE "user"`); - await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); - await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); - await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); - await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query('DROP TABLE "user"'); + await queryRunner.query('ALTER TABLE "temporary_user" RENAME TO "user"'); + await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"'); + await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"'); + await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"'); await queryRunner.query( - `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + 'CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))' ); await queryRunner.query( - `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "media"` + 'INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "media"' ); - await queryRunner.query(`DROP TABLE "media"`); - await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); + await queryRunner.query('DROP TABLE "media"'); + await queryRunner.query('ALTER TABLE "temporary_media" RENAME TO "media"'); await queryRunner.query( - `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + 'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ' ); await queryRunner.query( - `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + 'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ' ); await queryRunner.query( - `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + 'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ' ); await queryRunner.query( - `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + 'CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))' ); await queryRunner.query( - `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken" FROM "user"` + 'INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken" FROM "user"' ); - await queryRunner.query(`DROP TABLE "user"`); - await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); - await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); - await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); - await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query('DROP TABLE "user"'); + await queryRunner.query('ALTER TABLE "temporary_user" RENAME TO "user"'); + await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"'); + await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"'); + await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"'); await queryRunner.query( - `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + 'CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))' ); await queryRunner.query( - `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "media"` + 'INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "media"' ); - await queryRunner.query(`DROP TABLE "media"`); - await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); + await queryRunner.query('DROP TABLE "media"'); + await queryRunner.query('ALTER TABLE "temporary_media" RENAME TO "media"'); await queryRunner.query( - `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + 'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ' ); await queryRunner.query( - `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + 'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ' ); await queryRunner.query( - `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + 'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ' ); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); - await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); - await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); - await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); + await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"'); + await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"'); + await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"'); + await queryRunner.query('ALTER TABLE "media" RENAME TO "temporary_media"'); await queryRunner.query( - `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + 'CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))' ); await queryRunner.query( - `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "temporary_media"` + 'INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "temporary_media"' ); - await queryRunner.query(`DROP TABLE "temporary_media"`); + await queryRunner.query('DROP TABLE "temporary_media"'); await queryRunner.query( - `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + 'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ' ); await queryRunner.query( - `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + 'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ' ); await queryRunner.query( - `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + 'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ' ); - await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); + await queryRunner.query('ALTER TABLE "user" RENAME TO "temporary_user"'); await queryRunner.query( - `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + 'CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))' ); await queryRunner.query( - `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken" FROM "temporary_user"` + 'INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken" FROM "temporary_user"' ); - await queryRunner.query(`DROP TABLE "temporary_user"`); - await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); - await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); - await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); - await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); + await queryRunner.query('DROP TABLE "temporary_user"'); + await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"'); + await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"'); + await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"'); + await queryRunner.query('ALTER TABLE "media" RENAME TO "temporary_media"'); await queryRunner.query( - `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaID" varchar, "jellyfinMediaID4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + 'CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaID" varchar, "jellyfinMediaID4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))' ); await queryRunner.query( - `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "temporary_media"` + 'INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "temporary_media"' ); - await queryRunner.query(`DROP TABLE "temporary_media"`); + await queryRunner.query('DROP TABLE "temporary_media"'); await queryRunner.query( - `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + 'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ' ); await queryRunner.query( - `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + 'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ' ); await queryRunner.query( - `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + 'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ' ); - await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); + await queryRunner.query('ALTER TABLE "user" RENAME TO "temporary_user"'); await queryRunner.query( - `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "jellyfinUsername" varchar, "jellyfinId" varchar, "jellyfinAuthToken" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + 'CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "jellyfinUsername" varchar, "jellyfinId" varchar, "jellyfinAuthToken" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))' ); await queryRunner.query( - `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken" FROM "temporary_user"` + 'INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken" FROM "temporary_user"' ); - await queryRunner.query(`DROP TABLE "temporary_user"`); + await queryRunner.query('DROP TABLE "temporary_user"'); } } diff --git a/server/migration/1613955393450-UpdateUserSettingsRegions.ts b/server/migration/1613955393450-UpdateUserSettingsRegions.ts index 17c25ec29..d33df4eef 100644 --- a/server/migration/1613955393450-UpdateUserSettingsRegions.ts +++ b/server/migration/1613955393450-UpdateUserSettingsRegions.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class UpdateUserSettingsRegions1613955393450 - implements MigrationInterface { + implements MigrationInterface +{ name = 'UpdateUserSettingsRegions1613955393450'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts b/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts index 1e0175cc1..5e480d481 100644 --- a/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts +++ b/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class AddTelegramSettingsToUserSettings1614334195680 - implements MigrationInterface { + implements MigrationInterface +{ name = 'AddTelegramSettingsToUserSettings1614334195680'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts b/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts index c8bd6dd45..d498a8b17 100644 --- a/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts +++ b/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class CreateTagsFieldonMediaRequest1617624225464 - implements MigrationInterface { + implements MigrationInterface +{ name = 'CreateTagsFieldonMediaRequest1617624225464'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts b/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts index 86a52c089..79cd061b8 100644 --- a/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts +++ b/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserSettingsNotificationAgentsField1617730837489 - implements MigrationInterface { + implements MigrationInterface +{ name = 'AddUserSettingsNotificationAgentsField1617730837489'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1618912653565-CreateUserPushSubscriptions.ts b/server/migration/1618912653565-CreateUserPushSubscriptions.ts index 90ea0d3f9..539221d17 100644 --- a/server/migration/1618912653565-CreateUserPushSubscriptions.ts +++ b/server/migration/1618912653565-CreateUserPushSubscriptions.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class CreateUserPushSubscriptions1618912653565 - implements MigrationInterface { + implements MigrationInterface +{ name = 'CreateUserPushSubscriptions1618912653565'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts b/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts index 67d770722..cccdae2fa 100644 --- a/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts +++ b/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts @@ -1,7 +1,8 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserSettingsNotificationTypes1619339817343 - implements MigrationInterface { + implements MigrationInterface +{ name = 'AddUserSettingsNotificationTypes1619339817343'; public async up(queryRunner: QueryRunner): Promise { diff --git a/server/migration/1634904083966-AddIssues.ts b/server/migration/1634904083966-AddIssues.ts new file mode 100644 index 000000000..0c6116f9d --- /dev/null +++ b/server/migration/1634904083966-AddIssues.ts @@ -0,0 +1,55 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddIssues1634904083966 implements MigrationInterface { + name = 'AddIssues1634904083966'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "issue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "problemSeason" integer NOT NULL DEFAULT (0), "problemEpisode" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "createdById" integer, "modifiedById" integer)` + ); + await queryRunner.query( + `CREATE TABLE "issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "issueId" integer)` + ); + await queryRunner.query( + `CREATE TABLE "temporary_issue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "problemSeason" integer NOT NULL DEFAULT (0), "problemEpisode" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "createdById" integer, "modifiedById" integer, CONSTRAINT "FK_276e20d053f3cff1645803c95d8" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_10b17b49d1ee77e7184216001e0" FOREIGN KEY ("createdById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_da88a1019c850d1a7b143ca02e5" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_issue"("id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById") SELECT "id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById" FROM "issue"` + ); + await queryRunner.query(`DROP TABLE "issue"`); + await queryRunner.query(`ALTER TABLE "temporary_issue" RENAME TO "issue"`); + await queryRunner.query( + `CREATE TABLE "temporary_issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "issueId" integer, CONSTRAINT "FK_707b033c2d0653f75213614789d" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_180710fead1c94ca499c57a7d42" FOREIGN KEY ("issueId") REFERENCES "issue" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_issue_comment"("id", "message", "createdAt", "updatedAt", "userId", "issueId") SELECT "id", "message", "createdAt", "updatedAt", "userId", "issueId" FROM "issue_comment"` + ); + await queryRunner.query(`DROP TABLE "issue_comment"`); + await queryRunner.query( + `ALTER TABLE "temporary_issue_comment" RENAME TO "issue_comment"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "issue_comment" RENAME TO "temporary_issue_comment"` + ); + await queryRunner.query( + `CREATE TABLE "issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "issueId" integer)` + ); + await queryRunner.query( + `INSERT INTO "issue_comment"("id", "message", "createdAt", "updatedAt", "userId", "issueId") SELECT "id", "message", "createdAt", "updatedAt", "userId", "issueId" FROM "temporary_issue_comment"` + ); + await queryRunner.query(`DROP TABLE "temporary_issue_comment"`); + await queryRunner.query(`ALTER TABLE "issue" RENAME TO "temporary_issue"`); + await queryRunner.query( + `CREATE TABLE "issue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "problemSeason" integer NOT NULL DEFAULT (0), "problemEpisode" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "createdById" integer, "modifiedById" integer)` + ); + await queryRunner.query( + `INSERT INTO "issue"("id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById") SELECT "id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById" FROM "temporary_issue"` + ); + await queryRunner.query(`DROP TABLE "temporary_issue"`); + await queryRunner.query(`DROP TABLE "issue_comment"`); + await queryRunner.query(`DROP TABLE "issue"`); + } +} diff --git a/server/migration/1635079863457-AddPushbulletPushoverUserSettings.ts b/server/migration/1635079863457-AddPushbulletPushoverUserSettings.ts new file mode 100644 index 000000000..8934866fa --- /dev/null +++ b/server/migration/1635079863457-AddPushbulletPushoverUserSettings.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPushbulletPushoverUserSettings1635079863457 + implements MigrationInterface +{ + name = 'AddPushbulletPushoverUserSettings1635079863457'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "user_settings"` + ); + await queryRunner.query(`DROP TABLE "user_settings"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` + ); + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + } +} diff --git a/server/models/Collection.ts b/server/models/Collection.ts index 48112849b..9cc4f3788 100644 --- a/server/models/Collection.ts +++ b/server/models/Collection.ts @@ -1,3 +1,4 @@ +import { sortBy } from 'lodash'; import type { TmdbCollection } from '../api/themoviedb/interfaces'; import { MediaType } from '../constants/media'; import Media from '../entity/Media'; @@ -21,7 +22,7 @@ export const mapCollection = ( overview: collection.overview, posterPath: collection.poster_path, backdropPath: collection.backdrop_path, - parts: collection.parts.map((part) => + parts: sortBy(collection.parts, 'release_date').map((part) => mapMovieResult( part, media?.find( diff --git a/server/models/Movie.ts b/server/models/Movie.ts index ff8a96a47..ac19ce7e0 100644 --- a/server/models/Movie.ts +++ b/server/models/Movie.ts @@ -79,7 +79,7 @@ export interface MovieDetails { }; mediaInfo?: Media; externalIds: ExternalIds; - plexUrl?: string; + mediaUrl?: string; watchProviders?: WatchProviders[]; } diff --git a/server/models/Person.ts b/server/models/Person.ts index 14925edb6..087ab1c7b 100644 --- a/server/models/Person.ts +++ b/server/models/Person.ts @@ -1,11 +1,11 @@ import type { TmdbPersonCreditCast, TmdbPersonCreditCrew, - TmdbPersonDetail, + TmdbPersonDetails, } from '../api/themoviedb/interfaces'; import Media from '../entity/Media'; -export interface PersonDetail { +export interface PersonDetails { id: number; name: string; birthday: string; @@ -14,7 +14,7 @@ export interface PersonDetail { alsoKnownAs?: string[]; gender: number; biography: string; - popularity: string; + popularity: number; placeOfBirth?: string; profilePath?: string; adult: boolean; @@ -62,7 +62,7 @@ export interface CombinedCredit { crew: PersonCreditCrew[]; } -export const mapPersonDetails = (person: TmdbPersonDetail): PersonDetail => ({ +export const mapPersonDetails = (person: TmdbPersonDetails): PersonDetails => ({ id: person.id, name: person.name, birthday: person.birthday, diff --git a/server/models/Search.ts b/server/models/Search.ts index 0dab4e587..73427a378 100644 --- a/server/models/Search.ts +++ b/server/models/Search.ts @@ -1,6 +1,9 @@ import type { + TmdbMovieDetails, TmdbMovieResult, + TmdbPersonDetails, TmdbPersonResult, + TmdbTvDetails, TmdbTvResult, } from '../api/themoviedb/interfaces'; import { MediaType as MainMediaType } from '../constants/media'; @@ -140,3 +143,54 @@ export const mapSearchResults = ( return mapPersonResult(result); } }); + +export const mapMovieDetailsToResult = ( + movieDetails: TmdbMovieDetails +): TmdbMovieResult => ({ + id: movieDetails.id, + media_type: 'movie', + adult: movieDetails.adult, + genre_ids: movieDetails.genres.map((genre) => genre.id), + original_language: movieDetails.original_language, + original_title: movieDetails.original_title, + overview: movieDetails.overview ?? '', + popularity: movieDetails.popularity, + release_date: movieDetails.release_date, + title: movieDetails.title, + video: movieDetails.video, + vote_average: movieDetails.vote_average, + vote_count: movieDetails.vote_count, + backdrop_path: movieDetails.backdrop_path, + poster_path: movieDetails.poster_path, +}); + +export const mapTvDetailsToResult = ( + tvDetails: TmdbTvDetails +): TmdbTvResult => ({ + id: tvDetails.id, + media_type: 'tv', + first_air_date: tvDetails.first_air_date, + genre_ids: tvDetails.genres.map((genre) => genre.id), + name: tvDetails.name, + origin_country: tvDetails.origin_country, + original_language: tvDetails.original_language, + original_name: tvDetails.original_name, + overview: tvDetails.overview, + popularity: tvDetails.popularity, + vote_average: tvDetails.vote_average, + vote_count: tvDetails.vote_count, + backdrop_path: tvDetails.backdrop_path, + poster_path: tvDetails.poster_path, +}); + +export const mapPersonDetailsToResult = ( + personDetails: TmdbPersonDetails +): TmdbPersonResult => ({ + id: personDetails.id, + media_type: 'person', + name: personDetails.name, + popularity: personDetails.popularity, + adult: personDetails.adult, + profile_path: personDetails.profile_path, + known_for: [], +}); diff --git a/server/models/Tv.ts b/server/models/Tv.ts index 9f6b25687..b596b1d2b 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -90,6 +90,10 @@ export interface TvDetails { overview: string; popularity: number; productionCompanies: ProductionCompany[]; + productionCountries: { + iso_3166_1: string; + name: string; + }[]; spokenLanguages: SpokenLanguage[]; seasons: Season[]; status: string; @@ -187,6 +191,7 @@ export const mapTvDetails = ( originCountry: company.origin_country, logoPath: company.logo_path, })), + productionCountries: show.production_countries, contentRatings: show.content_ratings, spokenLanguages: show.spoken_languages.map((language) => ({ englishName: language.english_name, diff --git a/server/routes/auth.ts b/server/routes/auth.ts index b2a788d77..1aacb69b0 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -17,8 +17,7 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => { if (!req.user) { return res.status(500).json({ status: 500, - error: - 'Requested user endpoint without valid authenticated user in session', + error: 'Please sign in.', }); } const user = await userRepository.findOneOrFail({ @@ -31,12 +30,13 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => { authRoutes.post('/plex', async (req, res, next) => { const settings = getSettings(); const userRepository = getRepository(User); - const body = req.body as { - authToken?: string; - }; + const body = req.body as { authToken?: string }; if (!body.authToken) { - return res.status(500).json({ error: 'You must provide an auth token' }); + return next({ + status: 500, + message: 'Authentication token required.', + }); } if ( @@ -46,7 +46,7 @@ authRoutes.post('/plex', async (req, res, next) => { return res.status(500).json({ error: 'Plex login is disabled' }); } try { - // First we need to use this auth token to get the users email from plex.tv + // First we need to use this auth token to get the user's email from plex.tv const plextv = new PlexTvAPI(body.authToken); const account = await plextv.getUser(); @@ -59,74 +59,78 @@ authRoutes.post('/plex', async (req, res, next) => { }) .getOne(); - if (user) { - // Let's check if their Plex token is up-to-date - if (user.plexToken !== body.authToken) { - user.plexToken = body.authToken; - } - - // Update the user's avatar with their Plex thumbnail, in case it changed - user.avatar = account.thumb; - user.email = account.email; - user.plexUsername = account.username; - - // In case the user was previously a local account - if (user.userType === UserType.LOCAL) { - user.userType = UserType.PLEX; - user.plexId = account.id; - } + if (!user && !(await userRepository.count())) { + user = new User({ + email: account.email, + plexUsername: account.username, + plexId: account.id, + plexToken: account.authToken, + permissions: Permission.ADMIN, + avatar: account.thumb, + userType: UserType.PLEX, + }); await userRepository.save(user); } else { - // Here we check if it's the first user. If it is, we create the user with no check - // and give them admin permissions - const totalUsers = await userRepository.count(); + const mainUser = await userRepository.findOneOrFail({ + select: ['id', 'plexToken', 'plexId'], + order: { id: 'ASC' }, + }); + const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); - if (totalUsers === 0) { - user = new User({ - email: account.email, - plexUsername: account.username, - plexId: account.id, - plexToken: account.authToken, - permissions: Permission.ADMIN, - avatar: account.thumb, - userType: UserType.PLEX, - }); - await userRepository.save(user); + if ( + account.id === mainUser.plexId || + (await mainPlexTv.checkUserAccess(account.id)) + ) { + if (user) { + if (!user.plexId) { + logger.info( + 'Found matching Plex user; updating user with Plex data', + { + label: 'API', + ip: req.ip, + email: user.email, + userId: user.id, + plexId: account.id, + plexUsername: account.username, + } + ); + } - //Since we created the admin user, go ahead and set the mediaservertype to PLEX - settings.main.mediaServerType = MediaServerType.PLEX; - } + user.plexToken = body.authToken; + user.plexId = account.id; + user.avatar = account.thumb; + user.email = account.email; + user.plexUsername = account.username; + user.userType = UserType.PLEX; - // Double check that we didn't create the first admin user before running this - if (!user) { - if (!settings.main.newPlexLogin) { - logger.info( - 'Failed sign-in attempt from user who has not been imported to Jellyseerr.', + await userRepository.save(user); + } else if (!settings.main.newPlexLogin) { + logger.warn( + 'Failed sign-in attempt by unimported Plex user with access to the media server', { - label: 'Auth', - account: { - ...account, - authentication_token: '__REDACTED__', - authToken: '__REDACTED__', - }, + label: 'API', + ip: req.ip, + email: account.email, + plexId: account.id, + plexUsername: account.username, } ); return next({ status: 403, message: 'Access denied.', }); - } - - // If we get to this point, the user does not already exist so we need to create the - // user _assuming_ they have access to the Plex server - const mainUser = await userRepository.findOneOrFail({ - select: ['id', 'plexToken'], - order: { id: 'ASC' }, - }); - const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); - - if (await mainPlexTv.checkUserAccess(account.id)) { + } else { + logger.info( + 'Sign-in attempt from Plex user with access to the media server; creating new Overseerr user', + { + label: 'API', + ip: req.ip, + email: account.email, + plexId: account.id, + plexUsername: account.username, + } + ); user = new User({ email: account.email, plexUsername: account.username, @@ -136,24 +140,24 @@ authRoutes.post('/plex', async (req, res, next) => { avatar: account.thumb, userType: UserType.PLEX, }); + await userRepository.save(user); - } else { - logger.info( - 'Failed sign-in attempt from user without access to the Plex server.', - { - label: 'Auth', - account: { - ...account, - authentication_token: '__REDACTED__', - authToken: '__REDACTED__', - }, - } - ); - return next({ - status: 403, - message: 'Access denied.', - }); } + } else { + logger.warn( + 'Failed sign-in attempt by Plex user without access to the media server', + { + label: 'API', + ip: req.ip, + email: account.email, + plexId: account.id, + plexUsername: account.username, + } + ); + return next({ + status: 403, + message: 'Access denied.', + }); } } @@ -164,10 +168,14 @@ authRoutes.post('/plex', async (req, res, next) => { return res.status(200).json(user?.filter() ?? {}); } catch (e) { - logger.error(e.message, { label: 'Auth' }); + logger.error('Something went wrong authenticating with Plex account', { + label: 'API', + errorMessage: e.message, + ip: req.ip, + }); return next({ status: 500, - message: 'Something went wrong.', + message: 'Unable to authenticate.', }); } }); @@ -205,6 +213,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { settings.jellyfin.hostname !== '' ? settings.jellyfin.hostname : body.hostname; + // Try to find deviceId that corresponds to jellyfin user, else generate a new one let user = await userRepository.findOne({ where: { jellyfinUsername: body.username }, @@ -214,7 +223,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { if (user) { deviceId = user.jellyfinDeviceId ?? ''; } else { - deviceId = Buffer.from(`BOT_jellyseerr_${body.username ?? ''}`).toString( + deviceId = Buffer.from(`BOT_overseerr_${body.username ?? ''}`).toString( 'base64' ); } @@ -246,11 +255,33 @@ authRoutes.post('/jellyfin', async (req, res, next) => { user.username = ''; } await userRepository.save(user); + } else if (!settings.main.newPlexLogin) { + logger.warn( + 'Failed sign-in attempt by unimported Jellyfin user with access to the media server', + { + label: 'API', + ip: req.ip, + jellyfinUserId: account.User.Id, + jellyfinUsername: account.User.Name, + } + ); + return next({ + status: 403, + message: 'Access denied.', + }); } else { // Here we check if it's the first user. If it is, we create the user with no check // and give them admin permissions const totalUsers = await userRepository.count(); if (totalUsers === 0) { + logger.info( + 'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr', + { + label: 'API', + ip: req.ip, + jellyfinUsername: account.User.Name, + } + ); user = new User({ email: body.email, jellyfinUsername: account.User.Name, @@ -292,6 +323,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => { : '/os_logo_square.png', userType: UserType.JELLYFIN, }); + //initialize Jellyfin/Emby users with local login + const passedExplicitPassword = + body.password && body.password.length > 0; + if (passedExplicitPassword) { + await user.setPassword(body.password); + } await userRepository.save(user); } } @@ -340,7 +377,7 @@ authRoutes.post('/local', async (req, res, next) => { const body = req.body as { email?: string; password?: string }; if (!settings.main.localLogin) { - return res.status(500).json({ error: 'Local user sign-in is disabled.' }); + return res.status(500).json({ error: 'Password sign-in is disabled.' }); } else if (!body.email || !body.password) { return res.status(500).json({ error: 'You must provide both an email address and a password.', @@ -349,28 +386,90 @@ authRoutes.post('/local', async (req, res, next) => { try { const user = await userRepository .createQueryBuilder('user') - .select(['user.id', 'user.password']) + .select(['user.id', 'user.email', 'user.password', 'user.plexId']) .where('user.email = :email', { email: body.email.toLowerCase() }) .getOne(); - const isCorrectCredentials = await user?.passwordMatch(body.password); + if (!user || !(await user.passwordMatch(body.password))) { + logger.warn('Failed sign-in attempt using invalid Overseerr password', { + label: 'API', + ip: req.ip, + email: body.email, + userId: user?.id, + }); + return next({ + status: 403, + message: 'Access denied.', + }); + } - // User doesn't exist or credentials are incorrect - if (!isCorrectCredentials) { - logger.info( - 'Failed sign-in attempt from user with incorrect credentials.', + const mainUser = await userRepository.findOneOrFail({ + select: ['id', 'plexToken', 'plexId'], + order: { id: 'ASC' }, + }); + const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); + + if (!user.plexId) { + try { + const plexUsersResponse = await mainPlexTv.getUsers(); + const account = plexUsersResponse.MediaContainer.User.find( + (account) => + account.$.email && + account.$.email.toLowerCase() === user.email.toLowerCase() + )?.$; + + if ( + account && + (await mainPlexTv.checkUserAccess(parseInt(account.id))) + ) { + logger.info( + 'Found matching Plex user; updating user with Plex data', + { + label: 'API', + ip: req.ip, + email: body.email, + userId: user.id, + plexId: account.id, + plexUsername: account.username, + } + ); + + user.plexId = parseInt(account.id); + user.avatar = account.thumb; + user.email = account.email; + user.plexUsername = account.username; + user.userType = UserType.PLEX; + + await userRepository.save(user); + } + } catch (e) { + logger.error('Something went wrong fetching Plex users', { + label: 'API', + errorMessage: e.message, + }); + } + } + + if ( + user.plexId && + user.plexId !== mainUser.plexId && + !(await mainPlexTv.checkUserAccess(user.plexId)) + ) { + logger.warn( + 'Failed sign-in attempt from Plex user without access to the media server', { - label: 'Auth', + label: 'API', account: { ip: req.ip, email: body.email, - password: '__REDACTED__', + userId: user.id, + plexId: user.plexId, }, } ); return next({ status: 403, - message: 'Your sign-in credentials are incorrect.', + message: 'Access denied.', }); } @@ -381,13 +480,18 @@ authRoutes.post('/local', async (req, res, next) => { return res.status(200).json(user?.filter() ?? {}); } catch (e) { - logger.error('Something went wrong while attempting to authenticate.', { - label: 'Auth', - error: e.message, - }); + logger.error( + 'Something went wrong authenticating with Overseerr password', + { + label: 'API', + errorMessage: e.message, + ip: req.ip, + email: body.email, + } + ); return next({ status: 500, - message: 'Something went wrong.', + message: 'Unable to authenticate.', }); } }); @@ -397,7 +501,7 @@ authRoutes.post('/logout', (req, res, next) => { if (err) { return next({ status: 500, - message: 'Something went wrong while attempting to sign out.', + message: 'Something went wrong.', }); } @@ -405,14 +509,15 @@ authRoutes.post('/logout', (req, res, next) => { }); }); -authRoutes.post('/reset-password', async (req, res) => { +authRoutes.post('/reset-password', async (req, res, next) => { const userRepository = getRepository(User); const body = req.body as { email?: string }; if (!body.email) { - return res - .status(500) - .json({ error: 'You must provide an email address.' }); + return next({ + status: 500, + message: 'Email address required.', + }); } const user = await userRepository @@ -423,14 +528,16 @@ authRoutes.post('/reset-password', async (req, res) => { if (user) { await user.resetPassword(); userRepository.save(user); - logger.info('Successful request made for recovery link.', { - label: 'User Management', - context: { ip: req.ip, email: body.email }, + logger.info('Successfully sent password reset link', { + label: 'API', + ip: req.ip, + email: body.email, }); } else { - logger.info('Failed request made to reset a password.', { - label: 'User Management', - context: { ip: req.ip, email: body.email }, + logger.error('Something went wrong sending password reset link', { + label: 'API', + ip: req.ip, + email: body.email, }); } @@ -440,48 +547,59 @@ authRoutes.post('/reset-password', async (req, res) => { authRoutes.post('/reset-password/:guid', async (req, res, next) => { const userRepository = getRepository(User); - try { - if (!req.body.password || req.body.password?.length < 8) { - const message = - 'Failed to reset password. Password must be at least 8 characters long.'; - logger.info(message, { - label: 'User Management', - context: { ip: req.ip, guid: req.params.guid }, - }); - return next({ status: 500, message: message }); - } - - const user = await userRepository.findOne({ - where: { resetPasswordGuid: req.params.guid }, + if (!req.body.password || req.body.password?.length < 8) { + logger.warn('Failed password reset attempt using invalid new password', { + label: 'API', + ip: req.ip, + guid: req.params.guid, }); - - if (!user) { - throw new Error('Guid invalid.'); - } - - if ( - !user.recoveryLinkExpirationDate || - user.recoveryLinkExpirationDate <= new Date() - ) { - throw new Error('Recovery link expired.'); - } - - await user.setPassword(req.body.password); - user.recoveryLinkExpirationDate = null; - userRepository.save(user); - logger.info(`Successfully reset password`, { - label: 'User Management', - context: { ip: req.ip, guid: req.params.guid, email: user.email }, + return next({ + status: 500, + message: 'Password must be at least 8 characters long.', }); - - return res.status(200).json({ status: 'ok' }); - } catch (e) { - logger.info(`Failed to reset password. ${e.message}`, { - label: 'User Management', - context: { ip: req.ip, guid: req.params.guid }, - }); - return res.status(200).json({ status: 'ok' }); } + + const user = await userRepository.findOne({ + where: { resetPasswordGuid: req.params.guid }, + }); + + if (!user) { + logger.warn('Failed password reset attempt using invalid recovery link', { + label: 'API', + ip: req.ip, + guid: req.params.guid, + }); + return next({ + status: 500, + message: 'Invalid password reset link.', + }); + } + + if ( + !user.recoveryLinkExpirationDate || + user.recoveryLinkExpirationDate <= new Date() + ) { + logger.warn('Failed password reset attempt using expired recovery link', { + label: 'API', + ip: req.ip, + guid: req.params.guid, + email: user.email, + }); + return next({ + status: 500, + message: 'Invalid password reset link.', + }); + } + user.recoveryLinkExpirationDate = null; + userRepository.save(user); + logger.info('Successfully reset password', { + label: 'API', + ip: req.ip, + guid: req.params.guid, + email: user.email, + }); + + return res.status(200).json({ status: 'ok' }); }); export default authRoutes; diff --git a/server/routes/collection.ts b/server/routes/collection.ts index 8ffbb51c9..aa8948736 100644 --- a/server/routes/collection.ts +++ b/server/routes/collection.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import TheMovieDb from '../api/themoviedb'; import Media from '../entity/Media'; +import logger from '../logger'; import { mapCollection } from '../models/Collection'; const collectionRoutes = Router(); @@ -20,7 +21,15 @@ collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => { return res.status(200).json(mapCollection(collection, media)); } catch (e) { - return next({ status: 404, message: 'Collection does not exist' }); + logger.debug('Something went wrong retrieving collection', { + label: 'API', + errorMessage: e.message, + collectionId: req.params.id, + }); + return next({ + status: 500, + message: 'Unable to retrieve collection.', + }); } }); diff --git a/server/routes/discover.ts b/server/routes/discover.ts index dd3a9fa66..ea78bf03d 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -12,7 +12,7 @@ import { mapMovieResult, mapPersonResult, mapTvResult } from '../models/Search'; import { mapNetwork } from '../models/Tv'; import { isMovie, isPerson } from '../utils/typeHelpers'; -const createTmdbWithRegionLanaguage = (user?: User): TheMovieDb => { +export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => { const settings = getSettings(); const region = @@ -37,54 +37,15 @@ const createTmdbWithRegionLanaguage = (user?: User): TheMovieDb => { const discoverRoutes = Router(); -discoverRoutes.get('/movies', async (req, res) => { - const tmdb = createTmdbWithRegionLanaguage(req.user); - - const data = await tmdb.getDiscoverMovies({ - page: Number(req.query.page), - language: req.locale ?? (req.query.language as string), - genre: req.query.genre ? Number(req.query.genre) : undefined, - studio: req.query.studio ? Number(req.query.studio) : undefined, - }); - - const media = await Media.getRelatedMedia( - data.results.map((result) => result.id) - ); - - return res.status(200).json({ - page: data.page, - totalPages: data.total_pages, - totalResults: data.total_results, - results: data.results.map((result) => - mapMovieResult( - result, - media.find( - (req) => req.tmdbId === result.id && req.mediaType === MediaType.MOVIE - ) - ) - ), - }); -}); - -discoverRoutes.get<{ language: string }>( - '/movies/language/:language', - async (req, res, next) => { - const tmdb = createTmdbWithRegionLanaguage(req.user); - - const languages = await tmdb.getLanguages(); - - const language = languages.find( - (lang) => lang.iso_639_1 === req.params.language - ); - - if (!language) { - return next({ status: 404, message: 'Unable to retrieve language' }); - } +discoverRoutes.get('/movies', async (req, res, next) => { + const tmdb = createTmdbWithRegionLanguage(req.user); + try { const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), language: req.locale ?? (req.query.language as string), - originalLanguage: req.params.language, + genre: req.query.genre ? Number(req.query.genre) : undefined, + studio: req.query.studio ? Number(req.query.studio) : undefined, }); const media = await Media.getRelatedMedia( @@ -95,7 +56,6 @@ discoverRoutes.get<{ language: string }>( page: data.page, totalPages: data.total_pages, totalResults: data.total_results, - language, results: data.results.map((result) => mapMovieResult( result, @@ -106,51 +66,127 @@ discoverRoutes.get<{ language: string }>( ) ), }); + } catch (e) { + logger.debug('Something went wrong retrieving popular movies', { + label: 'API', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve popular movies.', + }); + } +}); + +discoverRoutes.get<{ language: string }>( + '/movies/language/:language', + async (req, res, next) => { + const tmdb = createTmdbWithRegionLanguage(req.user); + + try { + const languages = await tmdb.getLanguages(); + + const language = languages.find( + (lang) => lang.iso_639_1 === req.params.language + ); + + if (!language) { + return next({ status: 404, message: 'Language not found.' }); + } + + const data = await tmdb.getDiscoverMovies({ + page: Number(req.query.page), + language: req.locale ?? (req.query.language as string), + originalLanguage: req.params.language, + }); + + const media = await Media.getRelatedMedia( + data.results.map((result) => result.id) + ); + + return res.status(200).json({ + page: data.page, + totalPages: data.total_pages, + totalResults: data.total_results, + language, + results: data.results.map((result) => + mapMovieResult( + result, + media.find( + (req) => + req.tmdbId === result.id && req.mediaType === MediaType.MOVIE + ) + ) + ), + }); + } catch (e) { + logger.debug('Something went wrong retrieving movies by language', { + label: 'API', + errorMessage: e.message, + language: req.params.language, + }); + return next({ + status: 500, + message: 'Unable to retrieve movies by language.', + }); + } } ); discoverRoutes.get<{ genreId: string }>( '/movies/genre/:genreId', async (req, res, next) => { - const tmdb = createTmdbWithRegionLanaguage(req.user); + const tmdb = createTmdbWithRegionLanguage(req.user); - const genres = await tmdb.getMovieGenres({ - language: req.locale ?? (req.query.language as string), - }); + try { + const genres = await tmdb.getMovieGenres({ + language: req.locale ?? (req.query.language as string), + }); - const genre = genres.find( - (genre) => genre.id === Number(req.params.genreId) - ); + const genre = genres.find( + (genre) => genre.id === Number(req.params.genreId) + ); - if (!genre) { - return next({ status: 404, message: 'Unable to retrieve genre' }); - } + if (!genre) { + return next({ status: 404, message: 'Genre not found.' }); + } - const data = await tmdb.getDiscoverMovies({ - page: Number(req.query.page), - language: req.locale ?? (req.query.language as string), - genre: Number(req.params.genreId), - }); + const data = await tmdb.getDiscoverMovies({ + page: Number(req.query.page), + language: req.locale ?? (req.query.language as string), + genre: Number(req.params.genreId), + }); - const media = await Media.getRelatedMedia( - data.results.map((result) => result.id) - ); + const media = await Media.getRelatedMedia( + data.results.map((result) => result.id) + ); - return res.status(200).json({ - page: data.page, - totalPages: data.total_pages, - totalResults: data.total_results, - genre, - results: data.results.map((result) => - mapMovieResult( - result, - media.find( - (req) => - req.tmdbId === result.id && req.mediaType === MediaType.MOVIE + return res.status(200).json({ + page: data.page, + totalPages: data.total_pages, + totalResults: data.total_results, + genre, + results: data.results.map((result) => + mapMovieResult( + result, + media.find( + (req) => + req.tmdbId === result.id && req.mediaType === MediaType.MOVIE + ) ) - ) - ), - }); + ), + }); + } catch (e) { + logger.debug('Something went wrong retrieving movies by genre', { + label: 'API', + errorMessage: e.message, + genreId: req.params.genreId, + }); + return next({ + status: 500, + message: 'Unable to retrieve movies by genre.', + }); + } } ); @@ -188,13 +224,21 @@ discoverRoutes.get<{ studioId: string }>( ), }); } catch (e) { - return next({ status: 404, message: 'Unable to retrieve studio' }); + logger.debug('Something went wrong retrieving movies by studio', { + label: 'API', + errorMessage: e.message, + studioId: req.params.studioId, + }); + return next({ + status: 500, + message: 'Unable to retrieve movies by studio.', + }); } } ); -discoverRoutes.get('/movies/upcoming', async (req, res) => { - const tmdb = createTmdbWithRegionLanaguage(req.user); +discoverRoutes.get('/movies/upcoming', async (req, res, next) => { + const tmdb = createTmdbWithRegionLanguage(req.user); const now = new Date(); const offset = now.getTimezoneOffset(); @@ -202,79 +246,52 @@ discoverRoutes.get('/movies/upcoming', async (req, res) => { .toISOString() .split('T')[0]; - const data = await tmdb.getDiscoverMovies({ - page: Number(req.query.page), - language: req.locale ?? (req.query.language as string), - primaryReleaseDateGte: date, - }); - - const media = await Media.getRelatedMedia( - data.results.map((result) => result.id) - ); - - return res.status(200).json({ - page: data.page, - totalPages: data.total_pages, - totalResults: data.total_results, - results: data.results.map((result) => - mapMovieResult( - result, - media.find( - (med) => med.tmdbId === result.id && med.mediaType === MediaType.MOVIE - ) - ) - ), - }); -}); - -discoverRoutes.get('/tv', async (req, res) => { - const tmdb = createTmdbWithRegionLanaguage(req.user); - - const data = await tmdb.getDiscoverTv({ - page: Number(req.query.page), - language: req.locale ?? (req.query.language as string), - genre: req.query.genre ? Number(req.query.genre) : undefined, - network: req.query.network ? Number(req.query.network) : undefined, - }); - - const media = await Media.getRelatedMedia( - data.results.map((result) => result.id) - ); - - return res.status(200).json({ - page: data.page, - totalPages: data.total_pages, - totalResults: data.total_results, - results: data.results.map((result) => - mapTvResult( - result, - media.find( - (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV - ) - ) - ), - }); -}); - -discoverRoutes.get<{ language: string }>( - '/tv/language/:language', - async (req, res, next) => { - const tmdb = createTmdbWithRegionLanaguage(req.user); - - const languages = await tmdb.getLanguages(); - - const language = languages.find( - (lang) => lang.iso_639_1 === req.params.language - ); - - if (!language) { - return next({ status: 404, message: 'Unable to retrieve language' }); - } - - const data = await tmdb.getDiscoverTv({ + try { + const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), language: req.locale ?? (req.query.language as string), - originalLanguage: req.params.language, + primaryReleaseDateGte: date, + }); + + const media = await Media.getRelatedMedia( + data.results.map((result) => result.id) + ); + + return res.status(200).json({ + page: data.page, + totalPages: data.total_pages, + totalResults: data.total_results, + results: data.results.map((result) => + mapMovieResult( + result, + media.find( + (med) => + med.tmdbId === result.id && med.mediaType === MediaType.MOVIE + ) + ) + ), + }); + } catch (e) { + logger.debug('Something went wrong retrieving upcoming movies', { + label: 'API', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve upcoming movies.', + }); + } +}); + +discoverRoutes.get('/tv', async (req, res, next) => { + const tmdb = createTmdbWithRegionLanguage(req.user); + + try { + const data = await tmdb.getDiscoverTv({ + page: Number(req.query.page), + language: req.locale ?? (req.query.language as string), + genre: req.query.genre ? Number(req.query.genre) : undefined, + network: req.query.network ? Number(req.query.network) : undefined, }); const media = await Media.getRelatedMedia( @@ -285,7 +302,6 @@ discoverRoutes.get<{ language: string }>( page: data.page, totalPages: data.total_pages, totalResults: data.total_results, - language, results: data.results.map((result) => mapTvResult( result, @@ -295,50 +311,127 @@ discoverRoutes.get<{ language: string }>( ) ), }); + } catch (e) { + logger.debug('Something went wrong retrieving popular series', { + label: 'API', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve popular series.', + }); + } +}); + +discoverRoutes.get<{ language: string }>( + '/tv/language/:language', + async (req, res, next) => { + const tmdb = createTmdbWithRegionLanguage(req.user); + + try { + const languages = await tmdb.getLanguages(); + + const language = languages.find( + (lang) => lang.iso_639_1 === req.params.language + ); + + if (!language) { + return next({ status: 404, message: 'Language not found.' }); + } + + const data = await tmdb.getDiscoverTv({ + page: Number(req.query.page), + language: req.locale ?? (req.query.language as string), + originalLanguage: req.params.language, + }); + + const media = await Media.getRelatedMedia( + data.results.map((result) => result.id) + ); + + return res.status(200).json({ + page: data.page, + totalPages: data.total_pages, + totalResults: data.total_results, + language, + results: data.results.map((result) => + mapTvResult( + result, + media.find( + (med) => + med.tmdbId === result.id && med.mediaType === MediaType.TV + ) + ) + ), + }); + } catch (e) { + logger.debug('Something went wrong retrieving series by language', { + label: 'API', + errorMessage: e.message, + language: req.params.language, + }); + return next({ + status: 500, + message: 'Unable to retrieve series by language.', + }); + } } ); discoverRoutes.get<{ genreId: string }>( '/tv/genre/:genreId', async (req, res, next) => { - const tmdb = createTmdbWithRegionLanaguage(req.user); + const tmdb = createTmdbWithRegionLanguage(req.user); - const genres = await tmdb.getTvGenres({ - language: req.locale ?? (req.query.language as string), - }); + try { + const genres = await tmdb.getTvGenres({ + language: req.locale ?? (req.query.language as string), + }); - const genre = genres.find( - (genre) => genre.id === Number(req.params.genreId) - ); + const genre = genres.find( + (genre) => genre.id === Number(req.params.genreId) + ); - if (!genre) { - return next({ status: 404, message: 'Unable to retrieve genre' }); - } + if (!genre) { + return next({ status: 404, message: 'Genre not found.' }); + } - const data = await tmdb.getDiscoverTv({ - page: Number(req.query.page), - language: req.locale ?? (req.query.language as string), - genre: Number(req.params.genreId), - }); + const data = await tmdb.getDiscoverTv({ + page: Number(req.query.page), + language: req.locale ?? (req.query.language as string), + genre: Number(req.params.genreId), + }); - const media = await Media.getRelatedMedia( - data.results.map((result) => result.id) - ); + const media = await Media.getRelatedMedia( + data.results.map((result) => result.id) + ); - return res.status(200).json({ - page: data.page, - totalPages: data.total_pages, - totalResults: data.total_results, - genre, - results: data.results.map((result) => - mapTvResult( - result, - media.find( - (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV + return res.status(200).json({ + page: data.page, + totalPages: data.total_pages, + totalResults: data.total_results, + genre, + results: data.results.map((result) => + mapTvResult( + result, + media.find( + (med) => + med.tmdbId === result.id && med.mediaType === MediaType.TV + ) ) - ) - ), - }); + ), + }); + } catch (e) { + logger.debug('Something went wrong retrieving series by genre', { + label: 'API', + errorMessage: e.message, + genreId: req.params.genreId, + }); + return next({ + status: 500, + message: 'Unable to retrieve series by genre.', + }); + } } ); @@ -376,13 +469,21 @@ discoverRoutes.get<{ networkId: string }>( ), }); } catch (e) { - return next({ status: 404, message: 'Unable to retrieve network' }); + logger.debug('Something went wrong retrieving series by network', { + label: 'API', + errorMessage: e.message, + networkId: req.params.networkId, + }); + return next({ + status: 500, + message: 'Unable to retrieve series by network.', + }); } } ); -discoverRoutes.get('/tv/upcoming', async (req, res) => { - const tmdb = createTmdbWithRegionLanaguage(req.user); +discoverRoutes.get('/tv/upcoming', async (req, res, next) => { + const tmdb = createTmdbWithRegionLanguage(req.user); const now = new Date(); const offset = now.getTimezoneOffset(); @@ -390,76 +491,47 @@ discoverRoutes.get('/tv/upcoming', async (req, res) => { .toISOString() .split('T')[0]; - const data = await tmdb.getDiscoverTv({ - page: Number(req.query.page), - language: req.locale ?? (req.query.language as string), - firstAirDateGte: date, - }); + try { + const data = await tmdb.getDiscoverTv({ + page: Number(req.query.page), + language: req.locale ?? (req.query.language as string), + firstAirDateGte: date, + }); - const media = await Media.getRelatedMedia( - data.results.map((result) => result.id) - ); + const media = await Media.getRelatedMedia( + data.results.map((result) => result.id) + ); - return res.status(200).json({ - page: data.page, - totalPages: data.total_pages, - totalResults: data.total_results, - results: data.results.map((result) => - mapTvResult( - result, - media.find( - (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV + return res.status(200).json({ + page: data.page, + totalPages: data.total_pages, + totalResults: data.total_results, + results: data.results.map((result) => + mapTvResult( + result, + media.find( + (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV + ) ) - ) - ), - }); + ), + }); + } catch (e) { + logger.debug('Something went wrong retrieving upcoming series', { + label: 'API', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve upcoming series.', + }); + } }); -discoverRoutes.get('/trending', async (req, res) => { - const tmdb = createTmdbWithRegionLanaguage(req.user); +discoverRoutes.get('/trending', async (req, res, next) => { + const tmdb = createTmdbWithRegionLanguage(req.user); - const data = await tmdb.getAllTrending({ - page: Number(req.query.page), - language: req.locale ?? (req.query.language as string), - }); - - const media = await Media.getRelatedMedia( - data.results.map((result) => result.id) - ); - - return res.status(200).json({ - page: data.page, - totalPages: data.total_pages, - totalResults: data.total_results, - results: data.results.map((result) => - isMovie(result) - ? mapMovieResult( - result, - media.find( - (med) => - med.tmdbId === result.id && med.mediaType === MediaType.MOVIE - ) - ) - : isPerson(result) - ? mapPersonResult(result) - : mapTvResult( - result, - media.find( - (med) => - med.tmdbId === result.id && med.mediaType === MediaType.TV - ) - ) - ), - }); -}); - -discoverRoutes.get<{ keywordId: string }>( - '/keyword/:keywordId/movies', - async (req, res) => { - const tmdb = new TheMovieDb(); - - const data = await tmdb.getMoviesByKeyword({ - keywordId: Number(req.params.keywordId), + try { + const data = await tmdb.getAllTrending({ page: Number(req.query.page), language: req.locale ?? (req.query.language as string), }); @@ -473,15 +545,78 @@ discoverRoutes.get<{ keywordId: string }>( totalPages: data.total_pages, totalResults: data.total_results, results: data.results.map((result) => - mapMovieResult( - result, - media.find( - (med) => - med.tmdbId === result.id && med.mediaType === MediaType.MOVIE - ) - ) + isMovie(result) + ? mapMovieResult( + result, + media.find( + (med) => + med.tmdbId === result.id && med.mediaType === MediaType.MOVIE + ) + ) + : isPerson(result) + ? mapPersonResult(result) + : mapTvResult( + result, + media.find( + (med) => + med.tmdbId === result.id && med.mediaType === MediaType.TV + ) + ) ), }); + } catch (e) { + logger.debug('Something went wrong retrieving trending items', { + label: 'API', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve trending items.', + }); + } +}); + +discoverRoutes.get<{ keywordId: string }>( + '/keyword/:keywordId/movies', + async (req, res, next) => { + const tmdb = new TheMovieDb(); + + try { + const data = await tmdb.getMoviesByKeyword({ + keywordId: Number(req.params.keywordId), + page: Number(req.query.page), + language: req.locale ?? (req.query.language as string), + }); + + const media = await Media.getRelatedMedia( + data.results.map((result) => result.id) + ); + + return res.status(200).json({ + page: data.page, + totalPages: data.total_pages, + totalResults: data.total_results, + results: data.results.map((result) => + mapMovieResult( + result, + media.find( + (med) => + med.tmdbId === result.id && med.mediaType === MediaType.MOVIE + ) + ) + ), + }); + } catch (e) { + logger.debug('Something went wrong retrieving movies by keyword', { + label: 'API', + errorMessage: e.message, + keywordId: req.params.keywordId, + }); + return next({ + status: 500, + message: 'Unable to retrieve movies by keyword.', + }); + } } ); @@ -515,7 +650,8 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>( return res.status(200).json(sortedData); } catch (e) { - logger.error('Something went wrong retrieving the movie genre slider', { + logger.debug('Something went wrong retrieving the movie genre slider', { + label: 'API', errorMessage: e.message, }); return next({ @@ -556,12 +692,13 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>( return res.status(200).json(sortedData); } catch (e) { - logger.error('Something went wrong retrieving the tv genre slider', { + logger.debug('Something went wrong retrieving the series genre slider', { + label: 'API', errorMessage: e.message, }); return next({ status: 500, - message: 'Unable to retrieve tv genre slider.', + message: 'Unable to retrieve series genre slider.', }); } } diff --git a/server/routes/index.ts b/server/routes/index.ts index a12d08c72..e28666385 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -1,17 +1,22 @@ import { Router } from 'express'; import GithubAPI from '../api/github'; import TheMovieDb from '../api/themoviedb'; +import { TmdbMovieResult, TmdbTvResult } from '../api/themoviedb/interfaces'; import { StatusResponse } from '../interfaces/api/settingsInterfaces'; import { Permission } from '../lib/permissions'; import { getSettings } from '../lib/settings'; +import logger from '../logger'; import { checkUser, isAuthenticated } from '../middleware/auth'; import { mapProductionCompany } from '../models/Movie'; import { mapNetwork } from '../models/Tv'; import { appDataPath, appDataStatus } from '../utils/appDataVolume'; import { getAppVersion, getCommitTag } from '../utils/appVersion'; +import { isPerson } from '../utils/typeHelpers'; import authRoutes from './auth'; import collectionRoutes from './collection'; -import discoverRoutes from './discover'; +import discoverRoutes, { createTmdbWithRegionLanguage } from './discover'; +import issueRoutes from './issue'; +import issueCommentRoutes from './issueComment'; import mediaRoutes from './media'; import movieRoutes from './movie'; import personRoutes from './person'; @@ -106,63 +111,166 @@ router.use('/media', isAuthenticated(), mediaRoutes); router.use('/person', isAuthenticated(), personRoutes); router.use('/collection', isAuthenticated(), collectionRoutes); router.use('/service', isAuthenticated(), serviceRoutes); +router.use('/issue', isAuthenticated(), issueRoutes); +router.use('/issueComment', isAuthenticated(), issueCommentRoutes); router.use('/auth', authRoutes); -router.get('/regions', isAuthenticated(), async (req, res) => { +router.get('/regions', isAuthenticated(), async (req, res, next) => { const tmdb = new TheMovieDb(); - const regions = await tmdb.getRegions(); + try { + const regions = await tmdb.getRegions(); - return res.status(200).json(regions); + return res.status(200).json(regions); + } catch (e) { + logger.debug('Something went wrong retrieving regions', { + label: 'API', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve regions.', + }); + } }); -router.get('/languages', isAuthenticated(), async (req, res) => { +router.get('/languages', isAuthenticated(), async (req, res, next) => { const tmdb = new TheMovieDb(); - const languages = await tmdb.getLanguages(); + try { + const languages = await tmdb.getLanguages(); - return res.status(200).json(languages); + return res.status(200).json(languages); + } catch (e) { + logger.debug('Something went wrong retrieving languages', { + label: 'API', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve languages.', + }); + } }); -router.get<{ id: string }>('/studio/:id', async (req, res) => { +router.get<{ id: string }>('/studio/:id', async (req, res, next) => { const tmdb = new TheMovieDb(); - const studio = await tmdb.getStudio(Number(req.params.id)); + try { + const studio = await tmdb.getStudio(Number(req.params.id)); - return res.status(200).json(mapProductionCompany(studio)); + return res.status(200).json(mapProductionCompany(studio)); + } catch (e) { + logger.debug('Something went wrong retrieving studio', { + label: 'API', + errorMessage: e.message, + studioId: req.params.id, + }); + return next({ + status: 500, + message: 'Unable to retrieve studio.', + }); + } }); -router.get<{ id: string }>('/network/:id', async (req, res) => { +router.get<{ id: string }>('/network/:id', async (req, res, next) => { const tmdb = new TheMovieDb(); - const network = await tmdb.getNetwork(Number(req.params.id)); + try { + const network = await tmdb.getNetwork(Number(req.params.id)); - return res.status(200).json(mapNetwork(network)); + return res.status(200).json(mapNetwork(network)); + } catch (e) { + logger.debug('Something went wrong retrieving network', { + label: 'API', + errorMessage: e.message, + networkId: req.params.id, + }); + return next({ + status: 500, + message: 'Unable to retrieve network.', + }); + } }); -router.get('/genres/movie', isAuthenticated(), async (req, res) => { +router.get('/genres/movie', isAuthenticated(), async (req, res, next) => { const tmdb = new TheMovieDb(); - const genres = await tmdb.getMovieGenres({ - language: req.locale ?? (req.query.language as string), - }); + try { + const genres = await tmdb.getMovieGenres({ + language: req.locale ?? (req.query.language as string), + }); - return res.status(200).json(genres); + return res.status(200).json(genres); + } catch (e) { + logger.debug('Something went wrong retrieving movie genres', { + label: 'API', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve movie genres.', + }); + } }); -router.get('/genres/tv', isAuthenticated(), async (req, res) => { +router.get('/genres/tv', isAuthenticated(), async (req, res, next) => { const tmdb = new TheMovieDb(); - const genres = await tmdb.getTvGenres({ - language: req.locale ?? (req.query.language as string), - }); + try { + const genres = await tmdb.getTvGenres({ + language: req.locale ?? (req.query.language as string), + }); - return res.status(200).json(genres); + return res.status(200).json(genres); + } catch (e) { + logger.debug('Something went wrong retrieving series genres', { + label: 'API', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve series genres.', + }); + } +}); + +router.get('/backdrops', async (req, res, next) => { + const tmdb = createTmdbWithRegionLanguage(); + + try { + const data = ( + await tmdb.getAllTrending({ + page: 1, + timeWindow: 'week', + }) + ).results.filter((result) => !isPerson(result)) as ( + | TmdbMovieResult + | TmdbTvResult + )[]; + + return res + .status(200) + .json( + data + .map((result) => result.backdrop_path) + .filter((backdropPath) => !!backdropPath) + ); + } catch (e) { + logger.debug('Something went wrong retrieving backdrops', { + label: 'API', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve backdrops.', + }); + } }); router.get('/', (_req, res) => { return res.status(200).json({ - api: 'Jellyseerr API', + api: 'Overseerr API', version: '1.0', }); }); diff --git a/server/routes/issue.ts b/server/routes/issue.ts new file mode 100644 index 000000000..c7db5232c --- /dev/null +++ b/server/routes/issue.ts @@ -0,0 +1,332 @@ +import { Router } from 'express'; +import { getRepository } from 'typeorm'; +import { IssueStatus } from '../constants/issue'; +import Issue from '../entity/Issue'; +import IssueComment from '../entity/IssueComment'; +import Media from '../entity/Media'; +import { IssueResultsResponse } from '../interfaces/api/issueInterfaces'; +import { Permission } from '../lib/permissions'; +import logger from '../logger'; +import { isAuthenticated } from '../middleware/auth'; + +const issueRoutes = Router(); + +issueRoutes.get, IssueResultsResponse>( + '/', + isAuthenticated( + [ + Permission.MANAGE_ISSUES, + Permission.VIEW_ISSUES, + Permission.CREATE_ISSUES, + ], + { type: 'or' } + ), + async (req, res, next) => { + const pageSize = req.query.take ? Number(req.query.take) : 10; + const skip = req.query.skip ? Number(req.query.skip) : 0; + const createdBy = req.query.createdBy ? Number(req.query.createdBy) : null; + + let sortFilter: string; + + switch (req.query.sort) { + case 'modified': + sortFilter = 'issue.updatedAt'; + break; + default: + sortFilter = 'issue.createdAt'; + } + + let statusFilter: IssueStatus[]; + + switch (req.query.filter) { + case 'open': + statusFilter = [IssueStatus.OPEN]; + break; + case 'resolved': + statusFilter = [IssueStatus.RESOLVED]; + break; + default: + statusFilter = [IssueStatus.OPEN, IssueStatus.RESOLVED]; + } + + let query = getRepository(Issue) + .createQueryBuilder('issue') + .leftJoinAndSelect('issue.createdBy', 'createdBy') + .leftJoinAndSelect('issue.media', 'media') + .leftJoinAndSelect('issue.modifiedBy', 'modifiedBy') + .where('issue.status IN (:...issueStatus)', { + issueStatus: statusFilter, + }); + + if ( + !req.user?.hasPermission( + [Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], + { type: 'or' } + ) + ) { + if (createdBy && createdBy !== req.user?.id) { + return next({ + status: 403, + message: + 'You do not have permission to view issues reported by other users', + }); + } + query = query.andWhere('createdBy.id = :id', { id: req.user?.id }); + } else if (createdBy) { + query = query.andWhere('createdBy.id = :id', { id: createdBy }); + } + + const [issues, issueCount] = await query + .orderBy(sortFilter, 'DESC') + .take(pageSize) + .skip(skip) + .getManyAndCount(); + + return res.status(200).json({ + pageInfo: { + pages: Math.ceil(issueCount / pageSize), + pageSize, + results: issueCount, + page: Math.ceil(skip / pageSize) + 1, + }, + results: issues, + }); + } +); + +issueRoutes.post< + Record, + Issue, + { + message: string; + mediaId: number; + issueType: number; + problemSeason: number; + problemEpisode: number; + } +>( + '/', + isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], { + type: 'or', + }), + async (req, res, next) => { + // Satisfy typescript here. User is set, we assure you! + if (!req.user) { + return next({ status: 500, message: 'User missing from request.' }); + } + + const issueRepository = getRepository(Issue); + const mediaRepository = getRepository(Media); + + const media = await mediaRepository.findOne({ + where: { id: req.body.mediaId }, + }); + + if (!media) { + return next({ status: 404, message: 'Media does not exist.' }); + } + + const issue = new Issue({ + createdBy: req.user, + issueType: req.body.issueType, + problemSeason: req.body.problemSeason, + problemEpisode: req.body.problemEpisode, + media, + comments: [ + new IssueComment({ + user: req.user, + message: req.body.message, + }), + ], + }); + + const newIssue = await issueRepository.save(issue); + + return res.status(200).json(newIssue); + } +); + +issueRoutes.get<{ issueId: string }>( + '/:issueId', + isAuthenticated( + [ + Permission.MANAGE_ISSUES, + Permission.VIEW_ISSUES, + Permission.CREATE_ISSUES, + ], + { type: 'or' } + ), + async (req, res, next) => { + const issueRepository = getRepository(Issue); + // Satisfy typescript here. User is set, we assure you! + if (!req.user) { + return next({ status: 500, message: 'User missing from request.' }); + } + + try { + const issue = await issueRepository + .createQueryBuilder('issue') + .leftJoinAndSelect('issue.comments', 'comments') + .leftJoinAndSelect('issue.createdBy', 'createdBy') + .leftJoinAndSelect('comments.user', 'user') + .leftJoinAndSelect('issue.media', 'media') + .where('issue.id = :issueId', { issueId: Number(req.params.issueId) }) + .getOneOrFail(); + + if ( + issue.createdBy.id !== req.user.id && + !req.user.hasPermission( + [Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], + { type: 'or' } + ) + ) { + return next({ + status: 403, + message: 'You do not have permission to view this issue.', + }); + } + + return res.status(200).json(issue); + } catch (e) { + logger.debug('Failed to retrieve issue.', { + label: 'API', + errorMessage: e.message, + }); + next({ status: 500, message: 'Issue not found.' }); + } + } +); + +issueRoutes.post<{ issueId: string }, Issue, { message: string }>( + '/:issueId/comment', + isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], { + type: 'or', + }), + async (req, res, next) => { + const issueRepository = getRepository(Issue); + // Satisfy typescript here. User is set, we assure you! + if (!req.user) { + return next({ status: 500, message: 'User missing from request.' }); + } + + try { + const issue = await issueRepository.findOneOrFail({ + where: { id: Number(req.params.issueId) }, + }); + + if ( + issue.createdBy.id !== req.user.id && + !req.user.hasPermission(Permission.MANAGE_ISSUES) + ) { + return next({ + status: 403, + message: 'You do not have permission to comment on this issue.', + }); + } + + const comment = new IssueComment({ + message: req.body.message, + user: req.user, + }); + + issue.comments = [...issue.comments, comment]; + + await issueRepository.save(issue); + + return res.status(200).json(issue); + } catch (e) { + logger.debug('Something went wrong creating an issue comment.', { + label: 'API', + errorMessage: e.message, + }); + next({ status: 500, message: 'Issue not found.' }); + } + } +); + +issueRoutes.post<{ issueId: string; status: string }, Issue>( + '/:issueId/:status', + isAuthenticated(Permission.MANAGE_ISSUES), + async (req, res, next) => { + const issueRepository = getRepository(Issue); + // Satisfy typescript here. User is set, we assure you! + if (!req.user) { + return next({ status: 500, message: 'User missing from request.' }); + } + + try { + const issue = await issueRepository.findOneOrFail({ + where: { id: Number(req.params.issueId) }, + }); + + let newStatus: IssueStatus | undefined; + + switch (req.params.status) { + case 'resolved': + newStatus = IssueStatus.RESOLVED; + break; + case 'open': + newStatus = IssueStatus.OPEN; + } + + if (!newStatus) { + return next({ + status: 400, + message: 'You must provide a valid status', + }); + } + + issue.status = newStatus; + issue.modifiedBy = req.user; + + await issueRepository.save(issue); + + return res.status(200).json(issue); + } catch (e) { + logger.debug('Something went wrong creating an issue comment.', { + label: 'API', + errorMessage: e.message, + }); + next({ status: 500, message: 'Issue not found.' }); + } + } +); + +issueRoutes.delete( + '/:issueId', + isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], { + type: 'or', + }), + async (req, res, next) => { + const issueRepository = getRepository(Issue); + + try { + const issue = await issueRepository.findOneOrFail({ + where: { id: Number(req.params.issueId) }, + relations: ['createdBy'], + }); + + if ( + !req.user?.hasPermission(Permission.MANAGE_ISSUES) && + (issue.createdBy.id !== req.user?.id || issue.comments.length > 1) + ) { + return next({ + status: 401, + message: 'You do not have permission to delete this issue.', + }); + } + + await issueRepository.remove(issue); + + return res.status(204).send(); + } catch (e) { + logger.error('Something went wrong deleting an issue.', { + label: 'API', + errorMessage: e.message, + }); + next({ status: 404, message: 'Issue not found.' }); + } + } +); + +export default issueRoutes; diff --git a/server/routes/issueComment.ts b/server/routes/issueComment.ts new file mode 100644 index 000000000..c54bce5b6 --- /dev/null +++ b/server/routes/issueComment.ts @@ -0,0 +1,129 @@ +import { Router } from 'express'; +import { getRepository } from 'typeorm'; +import IssueComment from '../entity/IssueComment'; +import { Permission } from '../lib/permissions'; +import logger from '../logger'; +import { isAuthenticated } from '../middleware/auth'; + +const issueCommentRoutes = Router(); + +issueCommentRoutes.get<{ commentId: string }, IssueComment>( + '/:commentId', + isAuthenticated( + [ + Permission.MANAGE_ISSUES, + Permission.VIEW_ISSUES, + Permission.CREATE_ISSUES, + ], + { + type: 'or', + } + ), + async (req, res, next) => { + const issueCommentRepository = getRepository(IssueComment); + + try { + const comment = await issueCommentRepository.findOneOrFail({ + where: { id: Number(req.params.commentId) }, + }); + + if ( + !req.user?.hasPermission( + [Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], + { type: 'or' } + ) && + comment.user.id !== req.user?.id + ) { + return next({ + status: 403, + message: 'You do not have permission to view this comment.', + }); + } + + return res.status(200).json(comment); + } catch (e) { + logger.debug('Request for unknown issue comment failed', { + label: 'API', + errorMessage: e.message, + }); + next({ status: 404, message: 'Issue comment not found.' }); + } + } +); + +issueCommentRoutes.put< + { commentId: string }, + IssueComment, + { message: string } +>( + '/:commentId', + isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], { + type: 'or', + }), + async (req, res, next) => { + const issueCommentRepository = getRepository(IssueComment); + + try { + const comment = await issueCommentRepository.findOneOrFail({ + where: { id: Number(req.params.commentId) }, + }); + + if (comment.user.id !== req.user?.id) { + return next({ + status: 403, + message: 'You can only edit your own comments.', + }); + } + + comment.message = req.body.message; + + await issueCommentRepository.save(comment); + + return res.status(200).json(comment); + } catch (e) { + logger.debug('Put request for issue comment failed', { + label: 'API', + errorMessage: e.message, + }); + next({ status: 404, message: 'Issue comment not found.' }); + } + } +); + +issueCommentRoutes.delete<{ commentId: string }, IssueComment>( + '/:commentId', + isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], { + type: 'or', + }), + async (req, res, next) => { + const issueCommentRepository = getRepository(IssueComment); + + try { + const comment = await issueCommentRepository.findOneOrFail({ + where: { id: Number(req.params.commentId) }, + }); + + if ( + !req.user?.hasPermission([Permission.MANAGE_ISSUES], { type: 'or' }) && + comment.user.id !== req.user?.id + ) { + return next({ + status: 403, + message: 'You do not have permission to delete this comment.', + }); + } + + await issueCommentRepository.remove(comment); + + return res.status(204).send(); + } catch (e) { + logger.debug('Delete request for issue comment failed', { + label: 'API', + errorMessage: e.message, + }); + next({ status: 404, message: 'Issue comment not found.' }); + } + } +); + +export default issueCommentRoutes; diff --git a/server/routes/media.ts b/server/routes/media.ts index c77f77084..429b2010f 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -1,11 +1,17 @@ import { Router } from 'express'; -import { getRepository, FindOperator, FindOneOptions, In } from 'typeorm'; -import Media from '../entity/Media'; +import { FindOneOptions, FindOperator, getRepository, In } from 'typeorm'; +import TautulliAPI from '../api/tautulli'; import { MediaStatus, MediaType } from '../constants/media'; +import Media from '../entity/Media'; +import { User } from '../entity/User'; +import { + MediaResultsResponse, + MediaWatchDataResponse, +} from '../interfaces/api/mediaInterfaces'; +import { Permission } from '../lib/permissions'; +import { getSettings } from '../lib/settings'; import logger from '../logger'; import { isAuthenticated } from '../middleware/auth'; -import { Permission } from '../lib/permissions'; -import { MediaResultsResponse } from '../interfaces/api/mediaInterfaces'; const mediaRoutes = Router(); @@ -15,10 +21,8 @@ mediaRoutes.get('/', async (req, res, next) => { const pageSize = req.query.take ? Number(req.query.take) : 20; const skip = req.query.skip ? Number(req.query.skip) : 0; - let statusFilter: - | MediaStatus - | FindOperator - | undefined = undefined; + let statusFilter: MediaStatus | FindOperator | undefined = + undefined; switch (req.query.filter) { case 'available': @@ -163,4 +167,103 @@ mediaRoutes.delete( } ); +mediaRoutes.get<{ id: string }, MediaWatchDataResponse>( + '/:id/watch_data', + isAuthenticated(Permission.ADMIN), + async (req, res, next) => { + const settings = getSettings().tautulli; + + if (!settings.hostname || !settings.port || !settings.apiKey) { + return next({ + status: 404, + message: 'Tautulli API not configured.', + }); + } + + const media = await getRepository(Media).findOne({ + where: { id: Number(req.params.id) }, + }); + + if (!media) { + return next({ status: 404, message: 'Media does not exist.' }); + } + + try { + const tautulli = new TautulliAPI(settings); + const userRepository = getRepository(User); + + const response: MediaWatchDataResponse = {}; + + if (media.ratingKey) { + const watchStats = await tautulli.getMediaWatchStats(media.ratingKey); + const watchUsers = await tautulli.getMediaWatchUsers(media.ratingKey); + + const users = await userRepository + .createQueryBuilder('user') + .where('user.plexId IN (:...plexIds)', { + plexIds: watchUsers.map((u) => u.user_id), + }) + .getMany(); + + const playCount = + watchStats.find((i) => i.query_days == 0)?.total_plays ?? 0; + + const playCount7Days = + watchStats.find((i) => i.query_days == 7)?.total_plays ?? 0; + + const playCount30Days = + watchStats.find((i) => i.query_days == 30)?.total_plays ?? 0; + + response.data = { + users: users, + playCount, + playCount7Days, + playCount30Days, + }; + } + + if (media.ratingKey4k) { + const watchStats4k = await tautulli.getMediaWatchStats( + media.ratingKey4k + ); + const watchUsers4k = await tautulli.getMediaWatchUsers( + media.ratingKey4k + ); + + const users = await userRepository + .createQueryBuilder('user') + .where('user.plexId IN (:...plexIds)', { + plexIds: watchUsers4k.map((u) => u.user_id), + }) + .getMany(); + + const playCount = + watchStats4k.find((i) => i.query_days == 0)?.total_plays ?? 0; + + const playCount7Days = + watchStats4k.find((i) => i.query_days == 7)?.total_plays ?? 0; + + const playCount30Days = + watchStats4k.find((i) => i.query_days == 30)?.total_plays ?? 0; + + response.data4k = { + users, + playCount, + playCount7Days, + playCount30Days, + }; + } + + return res.status(200).json(response); + } catch (e) { + logger.error('Something went wrong fetching media watch data', { + label: 'API', + errorMessage: e.message, + mediaId: req.params.id, + }); + next({ status: 500, message: 'Failed to fetch watch data.' }); + } + } +); + export default mediaRoutes; diff --git a/server/routes/movie.ts b/server/routes/movie.ts index d871652a4..98474c78e 100644 --- a/server/routes/movie.ts +++ b/server/routes/movie.ts @@ -22,75 +22,105 @@ movieRoutes.get('/:id', async (req, res, next) => { return res.status(200).json(mapMovieDetails(tmdbMovie, media)); } catch (e) { - logger.error('Something went wrong getting movie', { - label: 'Movie', - message: e.message, + logger.debug('Something went wrong retrieving movie', { + label: 'API', + errorMessage: e.message, + movieId: req.params.id, + }); + return next({ + status: 500, + message: 'Unable to retrieve movie.', }); - return next({ status: 404, message: 'Movie does not exist' }); } }); -movieRoutes.get('/:id/recommendations', async (req, res) => { +movieRoutes.get('/:id/recommendations', async (req, res, next) => { const tmdb = new TheMovieDb(); - const results = await tmdb.getMovieRecommendations({ - movieId: Number(req.params.id), - page: Number(req.query.page), - language: req.locale ?? (req.query.language as string), - }); + try { + const results = await tmdb.getMovieRecommendations({ + movieId: Number(req.params.id), + page: Number(req.query.page), + language: req.locale ?? (req.query.language as string), + }); - const media = await Media.getRelatedMedia( - results.results.map((result) => result.id) - ); + const media = await Media.getRelatedMedia( + results.results.map((result) => result.id) + ); - return res.status(200).json({ - page: results.page, - totalPages: results.total_pages, - totalResults: results.total_results, - results: results.results.map((result) => - mapMovieResult( - result, - media.find( - (req) => req.tmdbId === result.id && req.mediaType === MediaType.MOVIE + return res.status(200).json({ + page: results.page, + totalPages: results.total_pages, + totalResults: results.total_results, + results: results.results.map((result) => + mapMovieResult( + result, + media.find( + (req) => + req.tmdbId === result.id && req.mediaType === MediaType.MOVIE + ) ) - ) - ), - }); + ), + }); + } catch (e) { + logger.debug('Something went wrong retrieving movie recommendations', { + label: 'API', + errorMessage: e.message, + movieId: req.params.id, + }); + return next({ + status: 500, + message: 'Unable to retrieve movie recommendations.', + }); + } }); -movieRoutes.get('/:id/similar', async (req, res) => { +movieRoutes.get('/:id/similar', async (req, res, next) => { const tmdb = new TheMovieDb(); - const results = await tmdb.getMovieSimilar({ - movieId: Number(req.params.id), - page: Number(req.query.page), - language: req.locale ?? (req.query.language as string), - }); + try { + const results = await tmdb.getMovieSimilar({ + movieId: Number(req.params.id), + page: Number(req.query.page), + language: req.locale ?? (req.query.language as string), + }); - const media = await Media.getRelatedMedia( - results.results.map((result) => result.id) - ); + const media = await Media.getRelatedMedia( + results.results.map((result) => result.id) + ); - return res.status(200).json({ - page: results.page, - totalPages: results.total_pages, - totalResults: results.total_results, - results: results.results.map((result) => - mapMovieResult( - result, - media.find( - (req) => req.tmdbId === result.id && req.mediaType === MediaType.MOVIE + return res.status(200).json({ + page: results.page, + totalPages: results.total_pages, + totalResults: results.total_results, + results: results.results.map((result) => + mapMovieResult( + result, + media.find( + (req) => + req.tmdbId === result.id && req.mediaType === MediaType.MOVIE + ) ) - ) - ), - }); + ), + }); + } catch (e) { + logger.debug('Something went wrong retrieving similar movies', { + label: 'API', + errorMessage: e.message, + movieId: req.params.id, + }); + return next({ + status: 500, + message: 'Unable to retrieve similar movies.', + }); + } }); movieRoutes.get('/:id/ratings', async (req, res, next) => { - try { - const tmdb = new TheMovieDb(); - const rtapi = new RottenTomatoes(); + const tmdb = new TheMovieDb(); + const rtapi = new RottenTomatoes(); + try { const movie = await tmdb.getMovie({ movieId: Number(req.params.id), }); @@ -101,12 +131,23 @@ movieRoutes.get('/:id/ratings', async (req, res, next) => { ); if (!rtratings) { - return next({ status: 404, message: 'Unable to retrieve ratings' }); + return next({ + status: 404, + message: 'Rotten Tomatoes ratings not found.', + }); } return res.status(200).json(rtratings); } catch (e) { - return next({ status: 404, message: 'Movie does not exist' }); + logger.debug('Something went wrong retrieving movie ratings', { + label: 'API', + errorMessage: e.message, + movieId: req.params.id, + }); + return next({ + status: 500, + message: 'Unable to retrieve movie ratings.', + }); } }); diff --git a/server/routes/person.ts b/server/routes/person.ts index e18e55c84..5093ae46c 100644 --- a/server/routes/person.ts +++ b/server/routes/person.ts @@ -20,52 +20,71 @@ personRoutes.get('/:id', async (req, res, next) => { }); return res.status(200).json(mapPersonDetails(person)); } catch (e) { - logger.error(e.message); - next({ status: 404, message: 'Person not found' }); + logger.debug('Something went wrong retrieving person', { + label: 'API', + errorMessage: e.message, + personId: req.params.id, + }); + return next({ + status: 500, + message: 'Unable to retrieve person.', + }); } }); -personRoutes.get('/:id/combined_credits', async (req, res) => { +personRoutes.get('/:id/combined_credits', async (req, res, next) => { const tmdb = new TheMovieDb(); - const combinedCredits = await tmdb.getPersonCombinedCredits({ - personId: Number(req.params.id), - language: req.locale ?? (req.query.language as string), - }); + try { + const combinedCredits = await tmdb.getPersonCombinedCredits({ + personId: Number(req.params.id), + language: req.locale ?? (req.query.language as string), + }); - const castMedia = await Media.getRelatedMedia( - combinedCredits.cast.map((result) => result.id) - ); + const castMedia = await Media.getRelatedMedia( + combinedCredits.cast.map((result) => result.id) + ); - const crewMedia = await Media.getRelatedMedia( - combinedCredits.crew.map((result) => result.id) - ); + const crewMedia = await Media.getRelatedMedia( + combinedCredits.crew.map((result) => result.id) + ); - return res.status(200).json({ - cast: combinedCredits.cast - .map((result) => - mapCastCredits( - result, - castMedia.find( - (med) => - med.tmdbId === result.id && med.mediaType === result.media_type + return res.status(200).json({ + cast: combinedCredits.cast + .map((result) => + mapCastCredits( + result, + castMedia.find( + (med) => + med.tmdbId === result.id && med.mediaType === result.media_type + ) ) ) - ) - .filter((item) => !item.adult), - crew: combinedCredits.crew - .map((result) => - mapCrewCredits( - result, - crewMedia.find( - (med) => - med.tmdbId === result.id && med.mediaType === result.media_type + .filter((item) => !item.adult), + crew: combinedCredits.crew + .map((result) => + mapCrewCredits( + result, + crewMedia.find( + (med) => + med.tmdbId === result.id && med.mediaType === result.media_type + ) ) ) - ) - .filter((item) => !item.adult), - id: combinedCredits.id, - }); + .filter((item) => !item.adult), + id: combinedCredits.id, + }); + } catch (e) { + logger.debug('Something went wrong retrieving combined credits', { + label: 'API', + errorMessage: e.message, + personId: req.params.id, + }); + return next({ + status: 500, + message: 'Unable to retrieve combined credits.', + }); + } }); export default personRoutes; diff --git a/server/routes/request.ts b/server/routes/request.ts index 8fed74107..cd269f4ef 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -13,131 +13,134 @@ import { isAuthenticated } from '../middleware/auth'; const requestRoutes = Router(); -requestRoutes.get('/', async (req, res, next) => { - try { - const pageSize = req.query.take ? Number(req.query.take) : 10; - const skip = req.query.skip ? Number(req.query.skip) : 0; - const requestedBy = req.query.requestedBy - ? Number(req.query.requestedBy) - : null; +requestRoutes.get, RequestResultsResponse>( + '/', + async (req, res, next) => { + try { + const pageSize = req.query.take ? Number(req.query.take) : 10; + const skip = req.query.skip ? Number(req.query.skip) : 0; + const requestedBy = req.query.requestedBy + ? Number(req.query.requestedBy) + : null; - let statusFilter: MediaRequestStatus[]; + let statusFilter: MediaRequestStatus[]; - switch (req.query.filter) { - case 'approved': - case 'processing': - case 'available': - statusFilter = [MediaRequestStatus.APPROVED]; - break; - case 'pending': - statusFilter = [MediaRequestStatus.PENDING]; - break; - case 'unavailable': - statusFilter = [ - MediaRequestStatus.PENDING, - MediaRequestStatus.APPROVED, - ]; - break; - default: - statusFilter = [ - MediaRequestStatus.PENDING, - MediaRequestStatus.APPROVED, - MediaRequestStatus.DECLINED, - ]; - } + switch (req.query.filter) { + case 'approved': + case 'processing': + case 'available': + statusFilter = [MediaRequestStatus.APPROVED]; + break; + case 'pending': + statusFilter = [MediaRequestStatus.PENDING]; + break; + case 'unavailable': + statusFilter = [ + MediaRequestStatus.PENDING, + MediaRequestStatus.APPROVED, + ]; + break; + default: + statusFilter = [ + MediaRequestStatus.PENDING, + MediaRequestStatus.APPROVED, + MediaRequestStatus.DECLINED, + ]; + } - let mediaStatusFilter: MediaStatus[]; + let mediaStatusFilter: MediaStatus[]; - switch (req.query.filter) { - case 'available': - mediaStatusFilter = [MediaStatus.AVAILABLE]; - break; - case 'processing': - case 'unavailable': - mediaStatusFilter = [ - MediaStatus.UNKNOWN, - MediaStatus.PENDING, - MediaStatus.PROCESSING, - MediaStatus.PARTIALLY_AVAILABLE, - ]; - break; - default: - mediaStatusFilter = [ - MediaStatus.UNKNOWN, - MediaStatus.PENDING, - MediaStatus.PROCESSING, - MediaStatus.PARTIALLY_AVAILABLE, - MediaStatus.AVAILABLE, - ]; - } + switch (req.query.filter) { + case 'available': + mediaStatusFilter = [MediaStatus.AVAILABLE]; + break; + case 'processing': + case 'unavailable': + mediaStatusFilter = [ + MediaStatus.UNKNOWN, + MediaStatus.PENDING, + MediaStatus.PROCESSING, + MediaStatus.PARTIALLY_AVAILABLE, + ]; + break; + default: + mediaStatusFilter = [ + MediaStatus.UNKNOWN, + MediaStatus.PENDING, + MediaStatus.PROCESSING, + MediaStatus.PARTIALLY_AVAILABLE, + MediaStatus.AVAILABLE, + ]; + } - let sortFilter: string; + let sortFilter: string; - switch (req.query.sort) { - case 'modified': - sortFilter = 'request.updatedAt'; - break; - default: - sortFilter = 'request.id'; - } + switch (req.query.sort) { + case 'modified': + sortFilter = 'request.updatedAt'; + break; + default: + sortFilter = 'request.id'; + } - let query = getRepository(MediaRequest) - .createQueryBuilder('request') - .leftJoinAndSelect('request.media', 'media') - .leftJoinAndSelect('request.seasons', 'seasons') - .leftJoinAndSelect('request.modifiedBy', 'modifiedBy') - .leftJoinAndSelect('request.requestedBy', 'requestedBy') - .where('request.status IN (:...requestStatus)', { - requestStatus: statusFilter, - }) - .andWhere( - '((request.is4k = 0 AND media.status IN (:...mediaStatus)) OR (request.is4k = 1 AND media.status4k IN (:...mediaStatus)))', - { - mediaStatus: mediaStatusFilter, + let query = getRepository(MediaRequest) + .createQueryBuilder('request') + .leftJoinAndSelect('request.media', 'media') + .leftJoinAndSelect('request.seasons', 'seasons') + .leftJoinAndSelect('request.modifiedBy', 'modifiedBy') + .leftJoinAndSelect('request.requestedBy', 'requestedBy') + .where('request.status IN (:...requestStatus)', { + requestStatus: statusFilter, + }) + .andWhere( + '((request.is4k = 0 AND media.status IN (:...mediaStatus)) OR (request.is4k = 1 AND media.status4k IN (:...mediaStatus)))', + { + mediaStatus: mediaStatusFilter, + } + ); + + if ( + !req.user?.hasPermission( + [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], + { type: 'or' } + ) + ) { + if (requestedBy && requestedBy !== req.user?.id) { + return next({ + status: 403, + message: "You do not have permission to view this user's requests.", + }); } - ); - if ( - !req.user?.hasPermission( - [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], - { type: 'or' } - ) - ) { - if (requestedBy && requestedBy !== req.user?.id) { - return next({ - status: 403, - message: "You do not have permission to view this user's requests.", + query = query.andWhere('requestedBy.id = :id', { + id: req.user?.id, + }); + } else if (requestedBy) { + query = query.andWhere('requestedBy.id = :id', { + id: requestedBy, }); } - query = query.andWhere('requestedBy.id = :id', { - id: req.user?.id, - }); - } else if (requestedBy) { - query = query.andWhere('requestedBy.id = :id', { - id: requestedBy, + const [requests, requestCount] = await query + .orderBy(sortFilter, 'DESC') + .take(pageSize) + .skip(skip) + .getManyAndCount(); + + return res.status(200).json({ + pageInfo: { + pages: Math.ceil(requestCount / pageSize), + pageSize, + results: requestCount, + page: Math.ceil(skip / pageSize) + 1, + }, + results: requests, }); + } catch (e) { + next({ status: 500, message: e.message }); } - - const [requests, requestCount] = await query - .orderBy(sortFilter, 'DESC') - .take(pageSize) - .skip(skip) - .getManyAndCount(); - - return res.status(200).json({ - pageInfo: { - pages: Math.ceil(requestCount / pageSize), - pageSize, - results: requestCount, - page: Math.ceil(skip / pageSize) + 1, - }, - results: requests, - } as RequestResultsResponse); - } catch (e) { - next({ status: 500, message: e.message }); } -}); +); requestRoutes.post('/', async (req, res, next) => { const tmdb = new TheMovieDb(); @@ -256,6 +259,9 @@ requestRoutes.post('/', async (req, res, next) => { .leftJoin('request.media', 'media') .where('request.is4k = :is4k', { is4k: req.body.is4k }) .andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id }) + .andWhere('media.mediaType = :mediaType', { + mediaType: MediaType.MOVIE, + }) .andWhere('request.status != :requestStatus', { requestStatus: MediaRequestStatus.DECLINED, }) @@ -441,6 +447,20 @@ requestRoutes.get('/count', async (_req, res, next) => { .createQueryBuilder('request') .leftJoinAndSelect('request.media', 'media'); + const totalCount = await query.getCount(); + + const movieCount = await query + .where('request.type = :requestType', { + requestType: MediaType.MOVIE, + }) + .getCount(); + + const tvCount = await query + .where('request.type = :requestType', { + requestType: MediaType.TV, + }) + .getCount(); + const pendingCount = await query .where('request.status = :requestStatus', { requestStatus: MediaRequestStatus.PENDING, @@ -453,12 +473,18 @@ requestRoutes.get('/count', async (_req, res, next) => { }) .getCount(); + const declinedCount = await query + .where('request.status = :requestStatus', { + requestStatus: MediaRequestStatus.DECLINED, + }) + .getCount(); + const processingCount = await query .where('request.status = :requestStatus', { requestStatus: MediaRequestStatus.APPROVED, }) .andWhere( - '(request.is4k = false AND media.status != :availableStatus) OR (request.is4k = true AND media.status4k != :availableStatus)', + '((request.is4k = false AND media.status != :availableStatus) OR (request.is4k = true AND media.status4k != :availableStatus))', { availableStatus: MediaStatus.AVAILABLE, } @@ -470,7 +496,7 @@ requestRoutes.get('/count', async (_req, res, next) => { requestStatus: MediaRequestStatus.APPROVED, }) .andWhere( - '(request.is4k = false AND media.status = :availableStatus) OR (request.is4k = true AND media.status4k = :availableStatus)', + '((request.is4k = false AND media.status = :availableStatus) OR (request.is4k = true AND media.status4k = :availableStatus))', { availableStatus: MediaStatus.AVAILABLE, } @@ -478,13 +504,21 @@ requestRoutes.get('/count', async (_req, res, next) => { .getCount(); return res.status(200).json({ + total: totalCount, + movie: movieCount, + tv: tvCount, pending: pendingCount, approved: approvedCount, + declined: declinedCount, processing: processingCount, available: availableCount, }); } catch (e) { - next({ status: 500, message: e.message }); + logger.error('Something went wrong retrieving request counts', { + label: 'API', + errorMessage: e.message, + }); + next({ status: 500, message: 'Unable to retrieve request counts.' }); } }); @@ -497,9 +531,26 @@ requestRoutes.get('/:requestId', async (req, res, next) => { relations: ['requestedBy', 'modifiedBy'], }); + if ( + request.requestedBy.id !== req.user?.id && + !req.user?.hasPermission( + [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], + { type: 'or' } + ) + ) { + return next({ + status: 403, + message: 'You do not have permission to view this request.', + }); + } + return res.status(200).json(request); } catch (e) { - next({ status: 404, message: 'Request not found' }); + logger.debug('Failed to retrieve request.', { + label: 'API', + errorMessage: e.message, + }); + next({ status: 404, message: 'Request not found.' }); } }); @@ -529,11 +580,11 @@ requestRoutes.put<{ requestId: string }>( }); } - let requestUser = req.user; + let requestUser = request.requestedBy; if ( req.body.userId && - req.body.userId !== req.user?.id && + req.body.userId !== request.requestedBy.id && !req.user?.hasPermission([ Permission.MANAGE_USERS, Permission.MANAGE_REQUESTS, @@ -665,7 +716,10 @@ requestRoutes.delete('/:requestId', async (req, res, next) => { return res.status(204).send(); } catch (e) { - logger.error(e.message); + logger.error('Something went wrong deleting a request.', { + label: 'API', + errorMessage: e.message, + }); next({ status: 404, message: 'Request not found.' }); } }); diff --git a/server/routes/search.ts b/server/routes/search.ts index c843e78c3..3f26a3939 100644 --- a/server/routes/search.ts +++ b/server/routes/search.ts @@ -1,29 +1,59 @@ import { Router } from 'express'; import TheMovieDb from '../api/themoviedb'; +import { TmdbSearchMultiResponse } from '../api/themoviedb/interfaces'; import Media from '../entity/Media'; +import { findSearchProvider } from '../lib/search'; +import logger from '../logger'; import { mapSearchResults } from '../models/Search'; const searchRoutes = Router(); -searchRoutes.get('/', async (req, res) => { - const tmdb = new TheMovieDb(); +searchRoutes.get('/', async (req, res, next) => { + const queryString = req.query.query as string; + const searchProvider = findSearchProvider(queryString.toLowerCase()); + let results: TmdbSearchMultiResponse; - const results = await tmdb.searchMulti({ - query: req.query.query as string, - page: Number(req.query.page), - language: req.locale ?? (req.query.language as string), - }); + try { + if (searchProvider) { + const [id] = queryString + .toLowerCase() + .match(searchProvider.pattern) as RegExpMatchArray; + results = await searchProvider.search({ + id, + language: req.locale ?? (req.query.language as string), + query: queryString, + }); + } else { + const tmdb = new TheMovieDb(); - const media = await Media.getRelatedMedia( - results.results.map((result) => result.id) - ); + results = await tmdb.searchMulti({ + query: queryString, + page: Number(req.query.page), + language: req.locale ?? (req.query.language as string), + }); + } - return res.status(200).json({ - page: results.page, - totalPages: results.total_pages, - totalResults: results.total_results, - results: mapSearchResults(results.results, media), - }); + const media = await Media.getRelatedMedia( + results.results.map((result) => result.id) + ); + + return res.status(200).json({ + page: results.page, + totalPages: results.total_pages, + totalResults: results.total_results, + results: mapSearchResults(results.results, media), + }); + } catch (e) { + logger.debug('Something went wrong retrieving search results', { + label: 'API', + errorMessage: e.message, + query: req.query.query, + }); + return next({ + status: 500, + message: 'Unable to retrieve search results.', + }); + } }); export default searchRoutes; diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 1007ee7ab..8a5ef9a28 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -1,13 +1,16 @@ import { Router } from 'express'; import rateLimit from 'express-rate-limit'; import fs from 'fs'; -import { merge, omit } from 'lodash'; +import { merge, omit, set, sortBy } from 'lodash'; +import { rescheduleJob } from 'node-schedule'; import path from 'path'; +import semver from 'semver'; import { getRepository } from 'typeorm'; import { URL } from 'url'; import JellyfinAPI from '../../api/jellyfin'; import PlexAPI from '../../api/plexapi'; import PlexTvAPI from '../../api/plextv'; +import TautulliAPI from '../../api/tautulli'; import Media from '../../entity/Media'; import { MediaRequest } from '../../entity/MediaRequest'; import { User } from '../../entity/User'; @@ -25,6 +28,7 @@ import { plexFullScanner } from '../../lib/scanners/plex'; import { getSettings, Library, MainSettings } from '../../lib/settings'; import logger from '../../logger'; import { isAuthenticated } from '../../middleware/auth'; +import { appDataPath } from '../../utils/appDataVolume'; import { getAppVersion } from '../../utils/appVersion'; import notificationRoutes from './notifications'; import radarrRoutes from './radarr'; @@ -51,7 +55,7 @@ settingsRoutes.get('/main', (req, res, next) => { const settings = getSettings(); if (!req.user) { - return next({ status: 500, message: 'User missing from request' }); + return next({ status: 400, message: 'User missing from request.' }); } res.status(200).json(filteredMainSettings(req.user, settings.main)); @@ -72,7 +76,7 @@ settingsRoutes.post('/main/regenerate', (req, res, next) => { const main = settings.regenerateApiKey(); if (!req.user) { - return next({ status: 500, message: 'User missing from request' }); + return next({ status: 500, message: 'User missing from request.' }); } return res.status(200).json(filteredMainSettings(req.user, main)); @@ -99,16 +103,22 @@ settingsRoutes.post('/plex', async (req, res, next) => { const result = await plexClient.getStatus(); - if (result?.MediaContainer?.machineIdentifier) { - settings.plex.machineId = result.MediaContainer.machineIdentifier; - settings.plex.name = result.MediaContainer.friendlyName; - - settings.save(); + if (!result?.MediaContainer?.machineIdentifier) { + throw new Error('Server not found'); } + + settings.plex.machineId = result.MediaContainer.machineIdentifier; + settings.plex.name = result.MediaContainer.friendlyName; + + settings.save(); } catch (e) { + logger.error('Something went wrong testing Plex connection', { + label: 'API', + errorMessage: e.message, + }); return next({ status: 500, - message: `Failed to connect to Plex: ${e.message}`, + message: 'Unable to connect to Plex.', }); } @@ -181,9 +191,13 @@ settingsRoutes.get('/plex/devices/servers', async (req, res, next) => { } return res.status(200).json(devices); } catch (e) { + logger.error('Something went wrong retrieving Plex server list', { + label: 'API', + errorMessage: e.message, + }); return next({ status: 500, - message: `Failed to connect to Plex: ${e.message}`, + message: 'Unable to retrieve Plex server list.', }); } }); @@ -287,6 +301,34 @@ settingsRoutes.get('/jellyfin/library', async (req, res) => { return res.status(200).json(settings.jellyfin.libraries); }); +settingsRoutes.get('/jellyfin/users', async (req, res) => { + const settings = getSettings(); + + const userRepository = getRepository(User); + const admin = await userRepository.findOneOrFail({ + select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'], + order: { id: 'ASC' }, + }); + const jellyfinClient = new JellyfinAPI( + settings.jellyfin.hostname ?? '', + admin.jellyfinAuthToken ?? '', + admin.jellyfinDeviceId ?? '' + ); + + jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); + const resp = await jellyfinClient.getUsers(); + const users = resp.users.map((user) => ({ + username: user.Name, + id: user.Id, + thumb: user.PrimaryImageTag + ? `${settings.jellyfin.hostname}/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90` + : '/os_logo_square.png', + email: user.Name, + })); + + return res.status(200).json(users); +}); + settingsRoutes.get('/jellyfin/sync', (_req, res) => { return res.status(200).json(jobJellyfinFullSync.status()); }); @@ -299,6 +341,104 @@ settingsRoutes.post('/jellyfin/sync', (req, res) => { } return res.status(200).json(jobJellyfinFullSync.status()); }); +settingsRoutes.get('/tautulli', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.tautulli); +}); + +settingsRoutes.post('/tautulli', async (req, res, next) => { + const settings = getSettings(); + + Object.assign(settings.tautulli, req.body); + + try { + const tautulliClient = new TautulliAPI(settings.tautulli); + + const result = await tautulliClient.getInfo(); + + if (!semver.gte(semver.coerce(result?.tautulli_version) ?? '', '2.9.0')) { + throw new Error('Tautulli version not supported'); + } + + settings.save(); + } catch (e) { + logger.error('Something went wrong testing Tautulli connection', { + label: 'API', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to connect to Tautulli.', + }); + } + + return res.status(200).json(settings.tautulli); +}); + +settingsRoutes.get( + '/plex/users', + isAuthenticated(Permission.MANAGE_USERS), + async (req, res, next) => { + const userRepository = getRepository(User); + const qb = userRepository.createQueryBuilder('user'); + + try { + const admin = await userRepository.findOneOrFail({ + select: ['id', 'plexToken'], + order: { id: 'ASC' }, + }); + const plexApi = new PlexTvAPI(admin.plexToken ?? ''); + const plexUsers = (await plexApi.getUsers()).MediaContainer.User.map( + (user) => user.$ + ).filter((user) => user.email); + + const unimportedPlexUsers: { + id: string; + title: string; + username: string; + email: string; + thumb: string; + }[] = []; + + const existingUsers = await qb + .where('user.plexId IN (:...plexIds)', { + plexIds: plexUsers.map((plexUser) => plexUser.id), + }) + .orWhere('user.email IN (:...plexEmails)', { + plexEmails: plexUsers.map((plexUser) => plexUser.email.toLowerCase()), + }) + .getMany(); + + await Promise.all( + plexUsers.map(async (plexUser) => { + if ( + !existingUsers.find( + (user) => + user.plexId === parseInt(plexUser.id) || + user.email === plexUser.email.toLowerCase() + ) && + (await plexApi.checkUserAccess(parseInt(plexUser.id))) + ) { + unimportedPlexUsers.push(plexUser); + } + }) + ); + + return res.status(200).json(sortBy(unimportedPlexUsers, 'username')); + } catch (e) { + logger.error('Something went wrong getting unimported Plex users', { + label: 'API', + errorMessage: e.message, + }); + next({ + status: 500, + message: 'Unable to retrieve unimported Plex users.', + }); + } + } +); + settingsRoutes.get( '/logs', rateLimit({ windowMs: 60 * 1000, max: 50 }), @@ -325,34 +465,42 @@ settingsRoutes.get( } const logFile = process.env.CONFIG_DIRECTORY - ? `${process.env.CONFIG_DIRECTORY}/logs/jellyseerr.log` - : path.join(__dirname, '../../../config/logs/jellyseerr.log'); + ? `${process.env.CONFIG_DIRECTORY}/logs/.machinelogs.json` + : path.join(__dirname, '../../../config/logs/.machinelogs.json'); const logs: LogMessage[] = []; + const logMessageProperties = [ + 'timestamp', + 'level', + 'label', + 'message', + 'data', + ]; try { - fs.readFileSync(logFile) - .toString() + fs.readFileSync(logFile, 'utf-8') .split('\n') .forEach((line) => { if (!line.length) return; - const timestamp = line.match(new RegExp(/^.{24}/)) || []; - const level = line.match(new RegExp(/\s\[\w+\]/)) || []; - const label = line.match(new RegExp(/\]\[.+?\]/)) || []; - const message = line.match(new RegExp(/:\s([^{}]+)({.*})?/)) || []; + const logMessage = JSON.parse(line); - if (level.length && filter.includes(level[0].slice(2, -1))) { - logs.push({ - timestamp: timestamp[0], - level: level.length ? level[0].slice(2, -1) : '', - label: label.length ? label[0].slice(2, -1) : '', - message: message.length && message[1] ? message[1] : '', - data: - message.length && message[2] - ? JSON.parse(message[2]) - : undefined, - }); + if (!filter.includes(logMessage.level)) { + return; } + + if ( + !Object.keys(logMessage).every((key) => + logMessageProperties.includes(key) + ) + ) { + Object.keys(logMessage) + .filter((prop) => !logMessageProperties.includes(prop)) + .forEach((prop) => { + set(logMessage, `data.${prop}`, logMessage[prop]); + }); + } + + logs.push(logMessage); }); const displayedLogs = logs.reverse().slice(skip, skip + pageSize); @@ -367,13 +515,13 @@ settingsRoutes.get( results: displayedLogs, } as LogsResultsResponse); } catch (error) { - logger.error('Something went wrong while fetching the logs', { + logger.error('Something went wrong while retrieving logs', { label: 'Logs', errorMessage: error.message, }); return next({ status: 500, - message: 'Something went wrong while fetching the logs', + message: 'Unable to retrieve logs.', }); } } @@ -385,6 +533,7 @@ settingsRoutes.get('/jobs', (_req, res) => { id: job.id, name: job.name, type: job.type, + interval: job.interval, nextExecutionTime: job.job.nextInvocation(), running: job.running ? job.running() : false, })) @@ -395,7 +544,7 @@ settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => { const scheduledJob = scheduledJobs.find((job) => job.id === req.params.jobId); if (!scheduledJob) { - return next({ status: 404, message: 'Job not found' }); + return next({ status: 404, message: 'Job not found.' }); } scheduledJob.job.invoke(); @@ -404,6 +553,7 @@ settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => { id: scheduledJob.id, name: scheduledJob.name, type: scheduledJob.type, + interval: scheduledJob.interval, nextExecutionTime: scheduledJob.job.nextInvocation(), running: scheduledJob.running ? scheduledJob.running() : false, }); @@ -417,7 +567,7 @@ settingsRoutes.post<{ jobId: string }>( ); if (!scheduledJob) { - return next({ status: 404, message: 'Job not found' }); + return next({ status: 404, message: 'Job not found.' }); } if (scheduledJob.cancelFn) { @@ -428,12 +578,45 @@ settingsRoutes.post<{ jobId: string }>( id: scheduledJob.id, name: scheduledJob.name, type: scheduledJob.type, + interval: scheduledJob.interval, nextExecutionTime: scheduledJob.job.nextInvocation(), running: scheduledJob.running ? scheduledJob.running() : false, }); } ); +settingsRoutes.post<{ jobId: string }>( + '/jobs/:jobId/schedule', + (req, res, next) => { + const scheduledJob = scheduledJobs.find( + (job) => job.id === req.params.jobId + ); + + if (!scheduledJob) { + return next({ status: 404, message: 'Job not found.' }); + } + + const result = rescheduleJob(scheduledJob.job, req.body.schedule); + const settings = getSettings(); + + if (result) { + settings.jobs[scheduledJob.id].schedule = req.body.schedule; + settings.save(); + + return res.status(200).json({ + id: scheduledJob.id, + name: scheduledJob.name, + type: scheduledJob.type, + interval: scheduledJob.interval, + nextExecutionTime: scheduledJob.job.nextInvocation(), + running: scheduledJob.running ? scheduledJob.running() : false, + }); + } else { + return next({ status: 400, message: 'Invalid job schedule.' }); + } + } +); + settingsRoutes.get('/cache', (req, res) => { const caches = cacheManager.getAllCaches(); @@ -456,7 +639,7 @@ settingsRoutes.post<{ cacheId: AvailableCacheIds }>( return res.status(204).send(); } - next({ status: 404, message: 'Cache does not exist.' }); + next({ status: 404, message: 'Cache not found.' }); } ); @@ -485,6 +668,7 @@ settingsRoutes.get('/about', async (req, res) => { totalMediaItems, totalRequests, tz: process.env.TZ, + appDataPath: appDataPath(), } as SettingsAboutResponse); }); diff --git a/server/routes/settings/notifications.ts b/server/routes/settings/notifications.ts index bb21c7b60..5a337237d 100644 --- a/server/routes/settings/notifications.ts +++ b/server/routes/settings/notifications.ts @@ -1,7 +1,10 @@ import { Router } from 'express'; +import { User } from '../../entity/User'; import { Notification } from '../../lib/notifications'; +import { NotificationAgent } from '../../lib/notifications/agents/agent'; import DiscordAgent from '../../lib/notifications/agents/discord'; import EmailAgent from '../../lib/notifications/agents/email'; +import 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'; @@ -13,6 +16,14 @@ import { getSettings } from '../../lib/settings'; const notificationRoutes = Router(); +const sendTestNotification = async (agent: NotificationAgent, user: User) => + await agent.send(Notification.TEST_NOTIFICATION, { + notifyAdmin: false, + notifyUser: user, + subject: 'Test Notification', + message: 'Check check, 1, 2, 3. Are we coming in clear?', + }); + notificationRoutes.get('/discord', (_req, res) => { const settings = getSettings(); @@ -37,14 +48,7 @@ notificationRoutes.post('/discord/test', async (req, res, next) => { } const discordAgent = new DiscordAgent(req.body); - if ( - await discordAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }) - ) { + if (await sendTestNotification(discordAgent, req.user)) { return res.status(204).send(); } else { return next({ @@ -78,14 +82,7 @@ notificationRoutes.post('/slack/test', async (req, res, next) => { } const slackAgent = new SlackAgent(req.body); - if ( - await slackAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }) - ) { + if (await sendTestNotification(slackAgent, req.user)) { return res.status(204).send(); } else { return next({ @@ -119,14 +116,7 @@ notificationRoutes.post('/telegram/test', async (req, res, next) => { } const telegramAgent = new TelegramAgent(req.body); - if ( - await telegramAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }) - ) { + if (await sendTestNotification(telegramAgent, req.user)) { return res.status(204).send(); } else { return next({ @@ -160,14 +150,7 @@ notificationRoutes.post('/pushbullet/test', async (req, res, next) => { } const pushbulletAgent = new PushbulletAgent(req.body); - if ( - await pushbulletAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }) - ) { + if (await sendTestNotification(pushbulletAgent, req.user)) { return res.status(204).send(); } else { return next({ @@ -201,14 +184,7 @@ notificationRoutes.post('/pushover/test', async (req, res, next) => { } const pushoverAgent = new PushoverAgent(req.body); - if ( - await pushoverAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }) - ) { + if (await sendTestNotification(pushoverAgent, req.user)) { return res.status(204).send(); } else { return next({ @@ -242,14 +218,7 @@ notificationRoutes.post('/email/test', async (req, res, next) => { } const emailAgent = new EmailAgent(req.body); - if ( - await emailAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }) - ) { + if (await sendTestNotification(emailAgent, req.user)) { return res.status(204).send(); } else { return next({ @@ -283,14 +252,7 @@ notificationRoutes.post('/webpush/test', async (req, res, next) => { } const webpushAgent = new WebPushAgent(req.body); - if ( - await webpushAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }) - ) { + if (await sendTestNotification(webpushAgent, req.user)) { return res.status(204).send(); } else { return next({ @@ -369,14 +331,7 @@ notificationRoutes.post('/webhook/test', async (req, res, next) => { }; const webhookAgent = new WebhookAgent(testBody); - if ( - await webhookAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }) - ) { + if (await sendTestNotification(webhookAgent, req.user)) { return res.status(204).send(); } else { return next({ @@ -413,14 +368,7 @@ notificationRoutes.post('/lunasea/test', async (req, res, next) => { } const lunaseaAgent = new LunaSeaAgent(req.body); - if ( - await lunaseaAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }) - ) { + if (await sendTestNotification(lunaseaAgent, req.user)) { return res.status(204).send(); } else { return next({ @@ -430,4 +378,46 @@ notificationRoutes.post('/lunasea/test', async (req, res, next) => { } }); +notificationRoutes.get('/gotify', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.notifications.agents.gotify); +}); + +notificationRoutes.post('/gotify', (req, rest) => { + const settings = getSettings(); + + settings.notifications.agents.gotify = req.body; + settings.save(); + + rest.status(200).json(settings.notifications.agents.gotify); +}); + +notificationRoutes.post('/gotify/test', async (req, rest, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information is missing from request', + }); + } + + const gotifyAgent = new GotifyAgent(req.body); + if ( + await gotifyAgent.send(Notification.TEST_NOTIFICATION, { + notifyAdmin: false, + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }) + ) { + return rest.status(204).send(); + } else { + return next({ + status: 500, + message: 'Failed to send Gotify notification.', + }); + } +}); + export default notificationRoutes; diff --git a/server/routes/settings/radarr.ts b/server/routes/settings/radarr.ts index d250ea298..a33bfcdba 100644 --- a/server/routes/settings/radarr.ts +++ b/server/routes/settings/radarr.ts @@ -46,6 +46,10 @@ radarrRoutes.post< url: RadarrAPI.buildUrl(req.body, '/api/v3'), }); + const urlBase = await radarr + .getSystemStatus() + .then((value) => value.urlBase) + .catch(() => req.body.baseUrl); const profiles = await radarr.getProfiles(); const folders = await radarr.getRootFolders(); const tags = await radarr.getTags(); @@ -57,6 +61,7 @@ radarrRoutes.post< path: folder.path, })), tags, + urlBase, }); } catch (e) { logger.error('Failed to test Radarr', { diff --git a/server/routes/settings/sonarr.ts b/server/routes/settings/sonarr.ts index 4f63ebb37..da5a5bb3f 100644 --- a/server/routes/settings/sonarr.ts +++ b/server/routes/settings/sonarr.ts @@ -42,6 +42,10 @@ sonarrRoutes.post('/test', async (req, res, next) => { url: SonarrAPI.buildUrl(req.body, '/api/v3'), }); + const urlBase = await sonarr + .getSystemStatus() + .then((value) => value.urlBase) + .catch(() => req.body.baseUrl); const profiles = await sonarr.getProfiles(); const folders = await sonarr.getRootFolders(); const languageProfiles = await sonarr.getLanguageProfiles(); @@ -55,6 +59,7 @@ sonarrRoutes.post('/test', async (req, res, next) => { })), languageProfiles, tags, + urlBase, }); } catch (e) { logger.error('Failed to test Sonarr', { diff --git a/server/routes/tv.ts b/server/routes/tv.ts index 043e610f7..201e7afe3 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -21,104 +21,156 @@ tvRoutes.get('/:id', async (req, res, next) => { return res.status(200).json(mapTvDetails(tv, media)); } catch (e) { - logger.error('Failed to get tv show', { + logger.debug('Something went wrong retrieving series', { label: 'API', errorMessage: e.message, + tvId: req.params.id, + }); + return next({ + status: 500, + message: 'Unable to retrieve series.', }); - return next({ status: 404, message: 'TV Show does not exist' }); } }); -tvRoutes.get('/:id/season/:seasonNumber', async (req, res) => { +tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => { const tmdb = new TheMovieDb(); - const season = await tmdb.getTvSeason({ - tvId: Number(req.params.id), - seasonNumber: Number(req.params.seasonNumber), - language: req.locale ?? (req.query.language as string), - }); + try { + const season = await tmdb.getTvSeason({ + tvId: Number(req.params.id), + seasonNumber: Number(req.params.seasonNumber), + language: req.locale ?? (req.query.language as string), + }); - return res.status(200).json(mapSeasonWithEpisodes(season)); + return res.status(200).json(mapSeasonWithEpisodes(season)); + } catch (e) { + logger.debug('Something went wrong retrieving season', { + label: 'API', + errorMessage: e.message, + tvId: req.params.id, + seasonNumber: req.params.seasonNumber, + }); + return next({ + status: 500, + message: 'Unable to retrieve season.', + }); + } }); -tvRoutes.get('/:id/recommendations', async (req, res) => { +tvRoutes.get('/:id/recommendations', async (req, res, next) => { const tmdb = new TheMovieDb(); - const results = await tmdb.getTvRecommendations({ - tvId: Number(req.params.id), - page: Number(req.query.page), - language: req.locale ?? (req.query.language as string), - }); + try { + const results = await tmdb.getTvRecommendations({ + tvId: Number(req.params.id), + page: Number(req.query.page), + language: req.locale ?? (req.query.language as string), + }); - const media = await Media.getRelatedMedia( - results.results.map((result) => result.id) - ); + const media = await Media.getRelatedMedia( + results.results.map((result) => result.id) + ); - return res.status(200).json({ - page: results.page, - totalPages: results.total_pages, - totalResults: results.total_results, - results: results.results.map((result) => - mapTvResult( - result, - media.find( - (req) => req.tmdbId === result.id && req.mediaType === MediaType.TV + return res.status(200).json({ + page: results.page, + totalPages: results.total_pages, + totalResults: results.total_results, + results: results.results.map((result) => + mapTvResult( + result, + media.find( + (req) => req.tmdbId === result.id && req.mediaType === MediaType.TV + ) ) - ) - ), - }); + ), + }); + } catch (e) { + logger.debug('Something went wrong retrieving series recommendations', { + label: 'API', + errorMessage: e.message, + tvId: req.params.id, + }); + return next({ + status: 500, + message: 'Unable to retrieve series recommendations.', + }); + } }); -tvRoutes.get('/:id/similar', async (req, res) => { +tvRoutes.get('/:id/similar', async (req, res, next) => { const tmdb = new TheMovieDb(); - const results = await tmdb.getTvSimilar({ - tvId: Number(req.params.id), - page: Number(req.query.page), - language: req.locale ?? (req.query.language as string), - }); + try { + const results = await tmdb.getTvSimilar({ + tvId: Number(req.params.id), + page: Number(req.query.page), + language: req.locale ?? (req.query.language as string), + }); - const media = await Media.getRelatedMedia( - results.results.map((result) => result.id) - ); + const media = await Media.getRelatedMedia( + results.results.map((result) => result.id) + ); - return res.status(200).json({ - page: results.page, - totalPages: results.total_pages, - totalResults: results.total_results, - results: results.results.map((result) => - mapTvResult( - result, - media.find( - (req) => req.tmdbId === result.id && req.mediaType === MediaType.TV + return res.status(200).json({ + page: results.page, + totalPages: results.total_pages, + totalResults: results.total_results, + results: results.results.map((result) => + mapTvResult( + result, + media.find( + (req) => req.tmdbId === result.id && req.mediaType === MediaType.TV + ) ) - ) - ), - }); + ), + }); + } catch (e) { + logger.debug('Something went wrong retrieving similar series', { + label: 'API', + errorMessage: e.message, + tvId: req.params.id, + }); + return next({ + status: 500, + message: 'Unable to retrieve similar series.', + }); + } }); tvRoutes.get('/:id/ratings', async (req, res, next) => { const tmdb = new TheMovieDb(); const rtapi = new RottenTomatoes(); - const tv = await tmdb.getTvShow({ - tvId: Number(req.params.id), - }); + try { + const tv = await tmdb.getTvShow({ + tvId: Number(req.params.id), + }); - if (!tv) { - return next({ status: 404, message: 'TV Show does not exist' }); + const rtratings = await rtapi.getTVRatings( + tv.name, + tv.first_air_date ? Number(tv.first_air_date.slice(0, 4)) : undefined + ); + + if (!rtratings) { + return next({ + status: 404, + message: 'Rotten Tomatoes ratings not found.', + }); + } + + return res.status(200).json(rtratings); + } catch (e) { + logger.debug('Something went wrong retrieving series ratings', { + label: 'API', + errorMessage: e.message, + tvId: req.params.id, + }); + return next({ + status: 500, + message: 'Unable to retrieve series ratings.', + }); } - - const rtratings = await rtapi.getTVRatings( - tv.name, - tv.first_air_date ? Number(tv.first_air_date.slice(0, 4)) : undefined - ); - - if (!rtratings) { - return next({ status: 404, message: 'Unable to retrieve ratings' }); - } - - return res.status(200).json(rtratings); }); export default tvRoutes; diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 4bccc772d..308cb14a7 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -1,8 +1,13 @@ import { Router } from 'express'; import gravatarUrl from 'gravatar-url'; -import { getRepository, Not } from 'typeorm'; +import { findIndex, sortBy } from 'lodash'; +import { getRepository, In, Not } from 'typeorm'; +import JellyfinAPI from '../../api/jellyfin'; import PlexTvAPI from '../../api/plextv'; +import TautulliAPI from '../../api/tautulli'; +import { MediaType } from '../../constants/media'; import { UserType } from '../../constants/user'; +import Media from '../../entity/Media'; import { MediaRequest } from '../../entity/MediaRequest'; import { User } from '../../entity/User'; import { UserPushSubscription } from '../../entity/UserPushSubscription'; @@ -10,6 +15,7 @@ import { QuotaResponse, UserRequestsResponse, UserResultsResponse, + UserWatchDataResponse, } from '../../interfaces/api/userInterfaces'; import { hasPermission, Permission } from '../../lib/permissions'; import { getSettings } from '../../lib/settings'; @@ -109,7 +115,7 @@ router.post( const user = new User({ avatar: body.avatar ?? avatar, - username: body.username ?? body.email, + username: body.username, email: body.email, password: body.password, permissions: settings.main.defaultPermissions, @@ -194,14 +200,11 @@ router.use('/:id/settings', userSettingsRoutes); router.get<{ id: string }, UserRequestsResponse>( '/:id/requests', async (req, res, next) => { - const userRepository = getRepository(User); - const requestRepository = getRepository(MediaRequest); - const pageSize = req.query.take ? Number(req.query.take) : 20; const skip = req.query.skip ? Number(req.query.skip) : 0; try { - const user = await userRepository.findOne({ + const user = await getRepository(User).findOne({ where: { id: Number(req.params.id) }, }); @@ -209,12 +212,32 @@ router.get<{ id: string }, UserRequestsResponse>( return next({ status: 404, message: 'User not found.' }); } - const [requests, requestCount] = await requestRepository.findAndCount({ - where: { requestedBy: user }, - order: { id: 'DESC' }, - take: pageSize, - skip, - }); + if ( + user.id !== req.user?.id && + !req.user?.hasPermission( + [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], + { type: 'or' } + ) + ) { + return next({ + status: 403, + message: "You do not have permission to view this user's requests.", + }); + } + + const [requests, requestCount] = await getRepository(MediaRequest) + .createQueryBuilder('request') + .leftJoinAndSelect('request.media', 'media') + .leftJoinAndSelect('request.seasons', 'seasons') + .leftJoinAndSelect('request.modifiedBy', 'modifiedBy') + .leftJoinAndSelect('request.requestedBy', 'requestedBy') + .andWhere('requestedBy.id = :id', { + id: user.id, + }) + .orderBy('request.id', 'DESC') + .take(pageSize) + .skip(skip) + .getManyAndCount(); return res.status(200).json({ pageInfo: { @@ -383,6 +406,7 @@ router.post( try { const settings = getSettings(); const userRepository = getRepository(User); + const body = req.body as { plexIds: string[] } | undefined; // taken from auth.ts const mainUser = await userRepository.findOneOrFail({ @@ -417,7 +441,7 @@ router.post( user.plexId = parseInt(account.id); } await userRepository.save(user); - } else { + } else if (!body || body.plexIds.includes(account.id)) { if (await mainPlexTv.checkUserAccess(parseInt(account.id))) { const newUser = new User({ plexUsername: account.username, @@ -442,6 +466,89 @@ router.post( } ); +router.post( + '/import-from-jellyfin', + isAuthenticated(Permission.MANAGE_USERS), + async (req, res, next) => { + try { + const settings = getSettings(); + const userRepository = getRepository(User); + const body = req.body as { jellyfinUserIds: string[] }; + + // taken from auth.ts + const admin = await userRepository.findOneOrFail({ + select: [ + 'id', + 'jellyfinAuthToken', + 'jellyfinDeviceId', + 'jellyfinUserId', + ], + order: { id: 'ASC' }, + }); + const jellyfinClient = new JellyfinAPI( + settings.jellyfin.hostname ?? '', + admin.jellyfinAuthToken ?? '', + admin.jellyfinDeviceId ?? '' + ); + jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); + + const jellyfinUsersResponse = await jellyfinClient.getUsers(); + const createdUsers: User[] = []; + for (const account of jellyfinUsersResponse.users) { + if (account.Name) { + const user = await userRepository + .createQueryBuilder('user') + .where('user.jellyfinUserId = :id', { id: account.Id }) + .orWhere('user.email = :email', { + email: account.Name, + }) + .getOne(); + + const avatar = account.PrimaryImageTag + ? `${settings.jellyfin.hostname}/Users/${account.Id}/Images/Primary/?tag=${account.PrimaryImageTag}&quality=90` + : '/os_logo_square.png'; + + if (user) { + // Update the user's avatar with their Jellyfin thumbnail, in case it changed + user.avatar = avatar; + user.email = account.Name; + user.jellyfinUsername = account.Name; + + // In case the user was previously a local account + if (user.userType === UserType.LOCAL) { + user.userType = UserType.JELLYFIN; + user.jellyfinUserId = account.Id; + } + await userRepository.save(user); + } else if (!body || body.jellyfinUserIds.includes(account.Id)) { + // logger.error('CREATED USER', { + // label: 'API', + // }); + + const newUser = new User({ + jellyfinUsername: account.Name, + jellyfinUserId: account.Id, + jellyfinDeviceId: Buffer.from( + `BOT_overseerr_${account.Name ?? ''}` + ).toString('base64'), + email: account.Name, + permissions: settings.main.defaultPermissions, + avatar, + userType: UserType.JELLYFIN, + }); + await userRepository.save(newUser); + createdUsers.push(newUser); + } + } + } + + return res.status(201).json(User.filterMany(createdUsers)); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + router.get<{ id: string }, QuotaResponse>( '/:id/quota', async (req, res, next) => { @@ -457,7 +564,8 @@ router.get<{ id: string }, QuotaResponse>( ) { return next({ status: 403, - message: 'You do not have permission to access this endpoint.', + message: + "You do not have permission to view this user's request limits.", }); } @@ -474,4 +582,112 @@ router.get<{ id: string }, QuotaResponse>( } ); +router.get<{ id: string }, UserWatchDataResponse>( + '/:id/watch_data', + async (req, res, next) => { + if ( + Number(req.params.id) !== req.user?.id && + !req.user?.hasPermission(Permission.ADMIN) + ) { + return next({ + status: 403, + message: + "You do not have permission to view this user's recently watched media.", + }); + } + + const settings = getSettings().tautulli; + + if (!settings.hostname || !settings.port || !settings.apiKey) { + return next({ + status: 404, + message: 'Tautulli API not configured.', + }); + } + + try { + const user = await getRepository(User).findOneOrFail({ + where: { id: Number(req.params.id) }, + select: ['id', 'plexId'], + }); + + const tautulli = new TautulliAPI(settings); + + const watchStats = await tautulli.getUserWatchStats(user); + const watchHistory = await tautulli.getUserWatchHistory(user); + + const recentlyWatched = sortBy( + await getRepository(Media).find({ + where: [ + { + mediaType: MediaType.MOVIE, + ratingKey: In( + watchHistory + .filter((record) => record.media_type === 'movie') + .map((record) => record.rating_key) + ), + }, + { + mediaType: MediaType.MOVIE, + ratingKey4k: In( + watchHistory + .filter((record) => record.media_type === 'movie') + .map((record) => record.rating_key) + ), + }, + { + mediaType: MediaType.TV, + ratingKey: In( + watchHistory + .filter((record) => record.media_type === 'episode') + .map((record) => record.grandparent_rating_key) + ), + }, + { + mediaType: MediaType.TV, + ratingKey4k: In( + watchHistory + .filter((record) => record.media_type === 'episode') + .map((record) => record.grandparent_rating_key) + ), + }, + ], + }), + [ + (media) => + findIndex( + watchHistory, + (record) => + (!!media.ratingKey && + parseInt(media.ratingKey) === + (record.media_type === 'movie' + ? record.rating_key + : record.grandparent_rating_key)) || + (!!media.ratingKey4k && + parseInt(media.ratingKey4k) === + (record.media_type === 'movie' + ? record.rating_key + : record.grandparent_rating_key)) + ), + ] + ); + + return res.status(200).json({ + recentlyWatched, + playCount: watchStats.total_plays, + }); + } catch (e) { + logger.error('Something went wrong fetching user watch data', { + label: 'API', + errorMessage: e.message, + userId: req.params.id, + }); + next({ + status: 500, + message: 'Failed to fetch user watch data.', + }); + } + } +); + export default router; diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 226dcae09..0c53c94a0 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -51,6 +51,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>( return res.status(200).json({ username: user.username, + discordId: user.settings?.discordId, locale: user.settings?.locale, region: user.settings?.region, originalLanguage: user.settings?.originalLanguage, @@ -109,11 +110,13 @@ userSettingsRoutes.post< if (!user.settings) { user.settings = new UserSettings({ user: req.user, + discordId: req.body.discordId, locale: req.body.locale, region: req.body.region, originalLanguage: req.body.originalLanguage, }); } else { + user.settings.discordId = req.body.discordId; user.settings.locale = req.body.locale; user.settings.region = req.body.region; user.settings.originalLanguage = req.body.originalLanguage; @@ -123,8 +126,9 @@ userSettingsRoutes.post< return res.status(200).json({ username: user.username, - region: user.settings.region, + discordId: user.settings.discordId, locale: user.settings.locale, + region: user.settings.region, originalLanguage: user.settings.originalLanguage, }); } catch (e) { @@ -252,11 +256,16 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( return res.status(200).json({ emailEnabled: settings?.email.enabled, pgpKey: user.settings?.pgpKey, - discordEnabled: settings?.discord.enabled, - discordEnabledTypes: settings?.discord.enabled - ? settings?.discord.types - : 0, + discordEnabled: + settings?.discord.enabled && settings.discord.options.enableMentions, + discordEnabledTypes: + settings?.discord.enabled && settings.discord.options.enableMentions + ? settings.discord.types + : 0, discordId: user.settings?.discordId, + pushbulletAccessToken: user.settings?.pushbulletAccessToken, + pushoverApplicationToken: user.settings?.pushoverApplicationToken, + pushoverUserKey: user.settings?.pushoverUserKey, telegramEnabled: settings?.telegram.enabled, telegramBotUsername: settings?.telegram.options.botUsername, telegramChatId: user.settings?.telegramChatId, @@ -298,6 +307,9 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>( user: req.user, pgpKey: req.body.pgpKey, discordId: req.body.discordId, + pushbulletAccessToken: req.body.pushbulletAccessToken, + pushoverApplicationToken: req.body.pushoverApplicationToken, + pushoverUserKey: req.body.pushoverUserKey, telegramChatId: req.body.telegramChatId, telegramSendSilently: req.body.telegramSendSilently, notificationTypes: req.body.notificationTypes, @@ -305,6 +317,10 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>( } else { user.settings.pgpKey = req.body.pgpKey; user.settings.discordId = req.body.discordId; + user.settings.pushbulletAccessToken = req.body.pushbulletAccessToken; + user.settings.pushoverApplicationToken = + req.body.pushoverApplicationToken; + user.settings.pushoverUserKey = req.body.pushoverUserKey; user.settings.telegramChatId = req.body.telegramChatId; user.settings.telegramSendSilently = req.body.telegramSendSilently; user.settings.notificationTypes = Object.assign( @@ -319,6 +335,9 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>( return res.status(200).json({ pgpKey: user.settings?.pgpKey, discordId: user.settings?.discordId, + pushbulletAccessToken: user.settings?.pushbulletAccessToken, + pushoverApplicationToken: user.settings?.pushoverApplicationToken, + pushoverUserKey: user.settings?.pushoverUserKey, telegramChatId: user.settings?.telegramChatId, telegramSendSilently: user?.settings?.telegramSendSilently, notificationTypes: user.settings.notificationTypes, diff --git a/server/subscriber/IssueCommentSubscriber.ts b/server/subscriber/IssueCommentSubscriber.ts new file mode 100644 index 000000000..1b1b7b55c --- /dev/null +++ b/server/subscriber/IssueCommentSubscriber.ts @@ -0,0 +1,101 @@ +import { sortBy } from 'lodash'; +import { + EntitySubscriberInterface, + EventSubscriber, + getRepository, + InsertEvent, +} from 'typeorm'; +import TheMovieDb from '../api/themoviedb'; +import { IssueType, IssueTypeName } from '../constants/issue'; +import { MediaType } from '../constants/media'; +import IssueComment from '../entity/IssueComment'; +import Media from '../entity/Media'; +import notificationManager, { Notification } from '../lib/notifications'; +import { Permission } from '../lib/permissions'; +import logger from '../logger'; + +@EventSubscriber() +export class IssueCommentSubscriber + implements EntitySubscriberInterface +{ + public listenTo(): typeof IssueComment { + return IssueComment; + } + + private async sendIssueCommentNotification(entity: IssueComment) { + let title: string; + let image: string; + const tmdb = new TheMovieDb(); + + try { + const issue = ( + await getRepository(IssueComment).findOneOrFail({ + where: { id: entity.id }, + relations: ['issue'], + }) + ).issue; + + const media = await getRepository(Media).findOneOrFail({ + where: { id: issue.media.id }, + }); + + if (media.mediaType === MediaType.MOVIE) { + const movie = await tmdb.getMovie({ movieId: media.tmdbId }); + + title = `${movie.title}${ + movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' + }`; + image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`; + } else { + const tvshow = await tmdb.getTvShow({ tvId: media.tmdbId }); + + title = `${tvshow.name}${ + tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : '' + }`; + image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`; + } + + const [firstComment] = sortBy(issue.comments, 'id'); + + if (entity.id !== firstComment.id) { + // Send notifications to all issue managers + notificationManager.sendNotification(Notification.ISSUE_COMMENT, { + event: `New Comment on ${ + issue.issueType !== IssueType.OTHER + ? `${IssueTypeName[issue.issueType]} ` + : '' + }Issue`, + subject: title, + message: firstComment.message, + comment: entity, + issue, + media, + image, + notifyAdmin: true, + notifyUser: + !issue.createdBy.hasPermission(Permission.MANAGE_ISSUES) && + issue.createdBy.id !== entity.user.id + ? issue.createdBy + : undefined, + }); + } + } catch (e) { + logger.error( + 'Something went wrong sending issue comment notification(s)', + { + label: 'Notifications', + errorMessage: e.message, + commentId: entity.id, + } + ); + } + } + + public afterInsert(event: InsertEvent): void { + if (!event.entity) { + return; + } + + this.sendIssueCommentNotification(event.entity); + } +} diff --git a/server/subscriber/IssueSubscriber.ts b/server/subscriber/IssueSubscriber.ts new file mode 100644 index 000000000..b593095cd --- /dev/null +++ b/server/subscriber/IssueSubscriber.ts @@ -0,0 +1,134 @@ +import { sortBy } from 'lodash'; +import { + EntitySubscriberInterface, + EventSubscriber, + InsertEvent, + UpdateEvent, +} from 'typeorm'; +import TheMovieDb from '../api/themoviedb'; +import { IssueStatus, IssueType, IssueTypeName } from '../constants/issue'; +import { MediaType } from '../constants/media'; +import Issue from '../entity/Issue'; +import notificationManager, { Notification } from '../lib/notifications'; +import { Permission } from '../lib/permissions'; +import logger from '../logger'; + +@EventSubscriber() +export class IssueSubscriber implements EntitySubscriberInterface { + public listenTo(): typeof Issue { + return Issue; + } + + private async sendIssueNotification(entity: Issue, type: Notification) { + let title: string; + let image: string; + const tmdb = new TheMovieDb(); + + try { + if (entity.media.mediaType === MediaType.MOVIE) { + const movie = await tmdb.getMovie({ movieId: entity.media.tmdbId }); + + title = `${movie.title}${ + movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' + }`; + image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`; + } else { + const tvshow = await tmdb.getTvShow({ tvId: entity.media.tmdbId }); + + title = `${tvshow.name}${ + tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : '' + }`; + image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`; + } + + const [firstComment] = sortBy(entity.comments, 'id'); + const extra: { name: string; value: string }[] = []; + + if (entity.media.mediaType === MediaType.TV && entity.problemSeason > 0) { + extra.push({ + name: 'Affected Season', + value: entity.problemSeason.toString(), + }); + + if (entity.problemEpisode > 0) { + extra.push({ + name: 'Affected Episode', + value: entity.problemEpisode.toString(), + }); + } + } + + notificationManager.sendNotification(type, { + event: + type === Notification.ISSUE_CREATED + ? `New ${ + entity.issueType !== IssueType.OTHER + ? `${IssueTypeName[entity.issueType]} ` + : '' + }Issue Reported` + : type === Notification.ISSUE_RESOLVED + ? `${ + entity.issueType !== IssueType.OTHER + ? `${IssueTypeName[entity.issueType]} ` + : '' + }Issue Resolved` + : `${ + entity.issueType !== IssueType.OTHER + ? `${IssueTypeName[entity.issueType]} ` + : '' + }Issue Reopened`, + subject: title, + message: firstComment.message, + issue: entity, + media: entity.media, + image, + extra, + notifyAdmin: true, + notifyUser: + !entity.createdBy.hasPermission(Permission.MANAGE_ISSUES) && + (type === Notification.ISSUE_RESOLVED || + type === Notification.ISSUE_REOPENED) + ? entity.createdBy + : undefined, + }); + } catch (e) { + logger.error('Something went wrong sending issue notification(s)', { + label: 'Notifications', + errorMessage: e.message, + issueId: entity.id, + }); + } + } + + public afterInsert(event: InsertEvent): void { + if (!event.entity) { + return; + } + + this.sendIssueNotification(event.entity, Notification.ISSUE_CREATED); + } + + public beforeUpdate(event: UpdateEvent): void { + if (!event.entity) { + return; + } + + if ( + event.entity.status === IssueStatus.RESOLVED && + event.databaseEntity.status !== IssueStatus.RESOLVED + ) { + this.sendIssueNotification( + event.entity as Issue, + Notification.ISSUE_RESOLVED + ); + } else if ( + event.entity.status === IssueStatus.OPEN && + event.databaseEntity.status !== IssueStatus.OPEN + ) { + this.sendIssueNotification( + event.entity as Issue, + Notification.ISSUE_REOPENED + ); + } + } +} diff --git a/server/subscriber/MediaSubscriber.ts b/server/subscriber/MediaSubscriber.ts index fb9bf24c2..01752b0d1 100644 --- a/server/subscriber/MediaSubscriber.ts +++ b/server/subscriber/MediaSubscriber.ts @@ -3,6 +3,7 @@ import { EntitySubscriberInterface, EventSubscriber, getRepository, + Not, UpdateEvent, } from 'typeorm'; import TheMovieDb from '../api/themoviedb'; @@ -11,54 +12,89 @@ import Media from '../entity/Media'; import { MediaRequest } from '../entity/MediaRequest'; import Season from '../entity/Season'; import notificationManager, { Notification } from '../lib/notifications'; +import logger from '../logger'; @EventSubscriber() -export class MediaSubscriber implements EntitySubscriberInterface { - private async notifyAvailableMovie(entity: Media, dbEntity?: Media) { +export class MediaSubscriber implements EntitySubscriberInterface { + private async notifyAvailableMovie( + entity: Media, + dbEntity: Media, + is4k: boolean + ) { if ( - entity.status === MediaStatus.AVAILABLE && - dbEntity?.status !== MediaStatus.AVAILABLE + entity[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE && + dbEntity[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE ) { if (entity.mediaType === MediaType.MOVIE) { const requestRepository = getRepository(MediaRequest); const relatedRequests = await requestRepository.find({ - where: { media: entity, is4k: false }, + where: { + media: entity, + is4k, + status: Not(MediaRequestStatus.DECLINED), + }, }); if (relatedRequests.length > 0) { const tmdb = new TheMovieDb(); - const movie = await tmdb.getMovie({ movieId: entity.tmdbId }); - relatedRequests.forEach((request) => { - notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, { - notifyUser: request.requestedBy, - subject: `${movie.title}${ - movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' - }`, - message: truncate(movie.overview, { - length: 500, - separator: /\s/, - omission: '…', - }), - media: entity, - image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, - request: request, + try { + const movie = await tmdb.getMovie({ movieId: entity.tmdbId }); + + relatedRequests.forEach((request) => { + notificationManager.sendNotification( + Notification.MEDIA_AVAILABLE, + { + event: `${is4k ? '4K ' : ''}Movie Request Now Available`, + notifyAdmin: false, + notifyUser: request.requestedBy, + subject: `${movie.title}${ + movie.release_date + ? ` (${movie.release_date.slice(0, 4)})` + : '' + }`, + message: truncate(movie.overview, { + length: 500, + separator: /\s/, + omission: '…', + }), + media: entity, + image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, + request, + } + ); }); - }); + } catch (e) { + logger.error('Something went wrong sending media notification(s)', { + label: 'Notifications', + errorMessage: e.message, + mediaId: entity.id, + }); + } } } } } - private async notifyAvailableSeries(entity: Media, dbEntity: Media) { + private async notifyAvailableSeries( + entity: Media, + dbEntity: Media, + is4k: boolean + ) { const seasonRepository = getRepository(Season); const newAvailableSeasons = entity.seasons - .filter((season) => season.status === MediaStatus.AVAILABLE) + .filter( + (season) => + season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE + ) .map((season) => season.seasonNumber); const oldSeasonIds = dbEntity.seasons.map((season) => season.id); const oldSeasons = await seasonRepository.findByIds(oldSeasonIds); const oldAvailableSeasons = oldSeasons - .filter((season) => season.status === MediaStatus.AVAILABLE) + .filter( + (season) => + season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE + ) .map((season) => season.seasonNumber); const changedSeasons = newAvailableSeasons.filter( @@ -72,7 +108,11 @@ export class MediaSubscriber implements EntitySubscriberInterface { for (const changedSeasonNumber of changedSeasons) { const requests = await requestRepository.find({ - where: { media: entity, is4k: false }, + where: { + media: entity, + is4k, + status: Not(MediaRequestStatus.DECLINED), + }, }); const request = requests.find( (request) => @@ -89,29 +129,40 @@ export class MediaSubscriber implements EntitySubscriberInterface { processedSeasons.push( ...request.seasons.map((season) => season.seasonNumber) ); - const tv = await tmdb.getTvShow({ tvId: entity.tmdbId }); - notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, { - subject: `${tv.name}${ - tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : '' - }`, - message: truncate(tv.overview, { - length: 500, - separator: /\s/, - omission: '…', - }), - notifyUser: request.requestedBy, - image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`, - media: entity, - extra: [ - { - name: 'Seasons', - value: request.seasons - .map((season) => season.seasonNumber) - .join(', '), - }, - ], - request: request, - }); + + try { + const tv = await tmdb.getTvShow({ tvId: entity.tmdbId }); + notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, { + event: `${is4k ? '4K ' : ''}Series Request Now Available`, + subject: `${tv.name}${ + tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : '' + }`, + message: truncate(tv.overview, { + length: 500, + separator: /\s/, + omission: '…', + }), + notifyAdmin: false, + notifyUser: request.requestedBy, + image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`, + media: entity, + extra: [ + { + name: 'Requested Seasons', + value: request.seasons + .map((season) => season.seasonNumber) + .join(', '), + }, + ], + request, + }); + } catch (e) { + logger.error('Something went wrong sending media notification(s)', { + label: 'Notifications', + errorMessage: e.message, + mediaId: entity.id, + }); + } } } } @@ -144,7 +195,22 @@ export class MediaSubscriber implements EntitySubscriberInterface { event.entity.mediaType === MediaType.MOVIE && event.entity.status === MediaStatus.AVAILABLE ) { - this.notifyAvailableMovie(event.entity as Media, event.databaseEntity); + this.notifyAvailableMovie( + event.entity as Media, + event.databaseEntity, + false + ); + } + + if ( + event.entity.mediaType === MediaType.MOVIE && + event.entity.status4k === MediaStatus.AVAILABLE + ) { + this.notifyAvailableMovie( + event.entity as Media, + event.databaseEntity, + true + ); } if ( @@ -152,7 +218,23 @@ export class MediaSubscriber implements EntitySubscriberInterface { (event.entity.status === MediaStatus.AVAILABLE || event.entity.status === MediaStatus.PARTIALLY_AVAILABLE) ) { - this.notifyAvailableSeries(event.entity as Media, event.databaseEntity); + this.notifyAvailableSeries( + event.entity as Media, + event.databaseEntity, + false + ); + } + + if ( + event.entity.mediaType === MediaType.TV && + (event.entity.status4k === MediaStatus.AVAILABLE || + event.entity.status4k === MediaStatus.PARTIALLY_AVAILABLE) + ) { + this.notifyAvailableSeries( + event.entity as Media, + event.databaseEntity, + true + ); } if ( @@ -169,4 +251,8 @@ export class MediaSubscriber implements EntitySubscriberInterface { this.updateChildRequestStatus(event.entity as Media, true); } } + + public listenTo(): typeof Media { + return Media; + } } diff --git a/server/templates/email/generatedpassword/html.pug b/server/templates/email/generatedpassword/html.pug index 129695abb..2fcb2e095 100644 --- a/server/templates/email/generatedpassword/html.pug +++ b/server/templates/email/generatedpassword/html.pug @@ -6,25 +6,6 @@ head meta(name='viewport' content='width=device-width, initial-scale=1') meta(name='format-detection' content='telephone=no, date=no, address=no, email=no') link(href='https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' rel='stylesheet' media='screen') - //if mso - xml - o:officedocumentsettings - o:pixelsperinch 96 - style. - td, - th, - div, - p, - a, - h1, - h2, - h3, - h4, - h5, - h6 { - font-family: 'Segoe UI', sans-serif; - mso-line-height-rule: exactly; - } style. .title:hover * { text-decoration: underline; @@ -35,28 +16,35 @@ head width: 100% !important; } } -div(style='display: block; background-color: #111827;') - table(style='margin: 0 auto; font-family: Inter, Arial, Sans-Serif; color: #fff; font-size: 16px; width: 26rem;') +div(style='display: block; background-color: #111827; padding: 2.5rem 0;') + table(style='margin: 0 auto; font-family: Inter, Arial, sans-serif; color: #fff; font-size: 16px; width: 26rem;') tr td(style="text-align: center;") - a(href=applicationUrl) - img(src=applicationUrl +'/logo_full.png' style='width: 26rem; padding: 1rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;') + if applicationUrl + a(href=applicationUrl style='margin: 0 1rem;') + img(src=applicationUrl +'/logo_full.png' style='width: 26rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;') + else + div(style='margin: 0 1rem 2.5rem; font-size: 3em; font-weight: 700;') + | #{applicationTitle} + if recipientName !== recipientEmail + tr + td(style='text-align: center;') + div(style='margin: 1rem 0 0; font-size: 1.25em;') + | Hi, #{recipientName.replace(/\.|@/g, ((x) => x + '\ufeff'))}! tr td(style='text-align: center;') - div(style='margin: 0rem 1rem 1rem; font-size: 1.25em;') - span - | An account has been created for you at #{applicationTitle}. + div(style='margin: 1rem 0 0; font-size: 1.25em;') + | An account has been created for you at #{applicationTitle}. tr td(style='text-align: center;') - div(style='margin: 1rem 1rem 1rem; font-size: 1.25em;') - span - | Your new password is: - div(style='margin: 0rem 1rem 1rem; font-size: 1.25em;') - span + div(style='margin: 1rem 0 0; font-size: 1.25em;') + | Your password is: + div(style='font-size: 1.25em; font-weight: 500; line-height: 2.25em;') + span(style='padding: 0.5rem; font-weight: 500; border: 1px solid rgb(100,100,100); font-family: monospace;') | #{password} if applicationUrl tr td - a(href=applicationUrl style='display: block; margin: 1.5rem 3rem 2.5rem 3rem; text-decoration: none; font-size: 1.0em; line-height: 2.25em;') - span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255, 255, 255, 0.2);') + a(href=applicationUrl style='display: block; margin: 1.5rem 3rem 0; text-decoration: none; font-size: 1.0em; line-height: 2.25em;') + span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);') | Open #{applicationTitle} diff --git a/server/templates/email/media-issue/html.pug b/server/templates/email/media-issue/html.pug new file mode 100644 index 000000000..920542e0b --- /dev/null +++ b/server/templates/email/media-issue/html.pug @@ -0,0 +1,53 @@ +doctype html +head + meta(charset='utf-8') + meta(name='x-apple-disable-message-reformatting') + meta(http-equiv='x-ua-compatible' content='ie=edge') + meta(name='viewport' content='width=device-width, initial-scale=1') + meta(name='format-detection' content='telephone=no, date=no, address=no, email=no') + link(href='https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' rel='stylesheet' media='screen') + style. + .title:hover * { + text-decoration: underline; + } + @media only screen and (max-width:600px) { + table { + font-size: 20px !important; + width: 100% !important; + } + } +div(style='display: block; background-color: #111827; padding: 2.5rem 0;') + table(style='margin: 0 auto; font-family: Inter, Arial, sans-serif; color: #fff; font-size: 16px; width: 26rem;') + tr + td(style="text-align: center;") + if applicationUrl + a(href=applicationUrl style='margin: 0 1rem;') + img(src=applicationUrl +'/logo_full.png' style='width: 26rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;') + else + div(style='margin: 0 1rem 2.5rem; font-size: 3em; font-weight: 700;') + | #{applicationTitle} + if recipientName !== recipientEmail + tr + td(style='text-align: center;') + div(style='margin: 1rem 0 0; font-size: 1.25em;') + | Hi, #{recipientName.replace(/\.|@/g, ((x) => x + '\ufeff'))}! + tr + td(style='text-align: center;') + div(style='margin: 1rem 0 0; font-size: 1.25em;') + | #{body} + if issueComment + tr + td(style='text-align: center;') + div(style='margin: 1rem 0 0; font-size: 1.25em;') + | #{issueComment} + else if issueDescription + tr + td(style='text-align: center;') + div(style='margin: 1rem 0 0; font-size: 1.25em;') + | #{issueDescription} + if actionUrl + tr + td + a(href=actionUrl style='display: block; margin: 1.5rem 3rem 0; text-decoration: none; font-size: 1.0em; line-height: 2.25em;') + span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);') + | View Issue in #{applicationTitle} diff --git a/server/templates/email/media-issue/subject.pug b/server/templates/email/media-issue/subject.pug new file mode 100644 index 000000000..1bf154baa --- /dev/null +++ b/server/templates/email/media-issue/subject.pug @@ -0,0 +1 @@ +!= `${event} - ${mediaName} [${applicationTitle}]` diff --git a/server/templates/email/media-request/html.pug b/server/templates/email/media-request/html.pug index 6d3a97403..334095dfe 100644 --- a/server/templates/email/media-request/html.pug +++ b/server/templates/email/media-request/html.pug @@ -6,25 +6,6 @@ head meta(name='viewport' content='width=device-width, initial-scale=1') meta(name='format-detection' content='telephone=no, date=no, address=no, email=no') link(href='https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' rel='stylesheet' media='screen') - //if mso - xml - o:officedocumentsettings - o:pixelsperinch 96 - style. - td, - th, - div, - p, - a, - h1, - h2, - h3, - h4, - h5, - h6 { - font-family: 'Segoe UI', sans-serif; - mso-line-height-rule: exactly; - } style. .title:hover * { text-decoration: underline; @@ -35,31 +16,38 @@ head width: 100% !important; } } -div(style='display: block; background-color: #111827;') - table(style='margin: 0 auto; font-family: Inter, Arial, Sans-Serif; color: #fff; font-size: 16px; width: 26rem;') +div(style='display: block; background-color: #111827; padding: 2.5rem 0;') + table(style='margin: 0 auto; font-family: Inter, Arial, sans-serif; color: #fff; font-size: 16px; width: 26rem;') tr td(style="text-align: center;") - a(href=applicationUrl) - img(src=applicationUrl +'/logo_full.png' style='width: 26rem; padding: 1rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;') + if applicationUrl + a(href=applicationUrl style='margin: 0 1rem;') + img(src=applicationUrl +'/logo_full.png' style='width: 26rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;') + else + div(style='margin: 0 1rem 2.5rem; font-size: 3em; font-weight: 700;') + | #{applicationTitle} + if recipientName !== recipientEmail + tr + td(style='text-align: center;') + div(style='margin: 1rem 0 0; font-size: 1.25em;') + | Hi, #{recipientName.replace(/\.|@/g, ((x) => x + '\ufeff'))}! tr td(style='text-align: center;') - div(style='margin: 0rem 1rem 1rem; font-size: 1.25em;') - span - | #{body} + div(style='margin: 1rem 0 0; font-size: 1.25em;') + | #{body} tr td - div(style='box-sizing: border-box; margin: 0; width: 100%; color: #fff; border-radius: .75rem; padding: 1rem; border: 1px solid rgba(100, 100, 100, 1); background: linear-gradient(135deg, rgba(17, 24, 39, 0.47) 0%, rgb(17, 24, 39) 75%), url(' + imageUrl + ') center 25%/cover') + div(style='box-sizing: border-box; margin: 1.5rem 0 0; width: 100%; color: #fff; border-radius: .75rem; padding: 1rem; border: 1px solid rgb(100,100,100); background: linear-gradient(135deg, rgba(17,24,39,0.47) 0%, rgb(17,24,39) 75%), url(' + imageUrl + ') center 25%/cover') table(style='color: #fff; width: 100%;') tr td(style='vertical-align: top;') a(href=actionUrl style='display: block; max-width: 20rem; color: #fff; font-weight: 700; text-decoration: none; margin: 0 1rem 0.25rem 0; font-size: 1.3em; line-height: 1.25em; margin-bottom: 5px;' class='title') - span - | #{mediaName} + | #{mediaName} div(style='overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #d1d5db; font-size: .975em; line-height: 1.45em; padding-top: .25rem; padding-bottom: .25rem;') span(style='display: block;') b(style='color: #9ca3af; font-weight: 700;') | Requested By  - | #{requestedBy} + | #{requestedBy.replace(/\.|@/g, ((x) => x + '\ufeff'))} each extra in mediaExtra span(style='display: block;') b(style='color: #9ca3af; font-weight: 700;') @@ -76,6 +64,6 @@ div(style='display: block; background-color: #111827;') if actionUrl tr td - a(href=actionUrl style='display: block; margin: 1.5rem 3rem 2.5rem 3rem; text-decoration: none; font-size: 1.0em; line-height: 2.25em;') - span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255, 255, 255, 0.2);') - | Open in #{applicationTitle} + a(href=actionUrl style='display: block; margin: 1.5rem 3rem 0; text-decoration: none; font-size: 1.0em; line-height: 2.25em;') + span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);') + | View Media in #{applicationTitle} diff --git a/server/templates/email/media-request/subject.pug b/server/templates/email/media-request/subject.pug index a0f50fbab..1bf154baa 100644 --- a/server/templates/email/media-request/subject.pug +++ b/server/templates/email/media-request/subject.pug @@ -1 +1 @@ -!= `${requestType} - ${mediaName} [${applicationTitle}]` +!= `${event} - ${mediaName} [${applicationTitle}]` diff --git a/server/templates/email/resetpassword/html.pug b/server/templates/email/resetpassword/html.pug index a6fcc6468..3c800663a 100644 --- a/server/templates/email/resetpassword/html.pug +++ b/server/templates/email/resetpassword/html.pug @@ -6,25 +6,6 @@ head meta(name='viewport' content='width=device-width, initial-scale=1') meta(name='format-detection' content='telephone=no, date=no, address=no, email=no') link(href='https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' rel='stylesheet' media='screen') - //if mso - xml - o:officedocumentsettings - o:pixelsperinch 96 - style. - td, - th, - div, - p, - a, - h1, - h2, - h3, - h4, - h5, - h6 { - font-family: 'Segoe UI', sans-serif; - mso-line-height-rule: exactly; - } style. .title:hover * { text-decoration: underline; @@ -35,25 +16,35 @@ head width: 100% !important; } } -div(style='display: block; background-color: #111827;') - table(style='margin: 0 auto; font-family: Inter, Arial, Sans-Serif; color: #fff; font-size: 16px; width: 26rem;') +div(style='display: block; background-color: #111827; padding: 2.5rem 0;') + table(style='margin: 0 auto; font-family: Inter, Arial, sans-serif; color: #fff; font-size: 16px; width: 26rem;') tr td(style="text-align: center;") - a(href=applicationUrl) - img(src=applicationUrl +'/logo_full.png' style='width: 26rem; padding: 1rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;') - tr - td(style='text-align: center;') - div(style='margin: 0rem 1rem 1rem; font-size: 1.25em;') - span - | Your #{applicationTitle} account password was requested to be reset. Click below to reset your password. - if resetPasswordLink + if applicationUrl + a(href=applicationUrl style='margin: 0 1rem;') + img(src=applicationUrl +'/logo_full.png' style='width: 26rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;') + else + div(style='margin: 0 1rem 2.5rem; font-size: 3em; font-weight: 700;') + | #{applicationTitle} + if recipientName !== recipientEmail tr - td - a(href=resetPasswordLink style='display: block; margin: 1.5rem 3rem 2.5rem 3rem; text-decoration: none; font-size: 1.0em; line-height: 2.25em;') - span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255, 255, 255, 0.2);') - | Reset Password + td(style='text-align: center;') + div(style='margin: 1rem 0 0; font-size: 1.25em;') + | Hi, #{recipientName.replace(/\.|@/g, ((x) => x + '\ufeff'))}! tr td(style='text-align: center;') - div(style='margin: 1rem; font-size: .85em;') - span - | If you did not request that your password be reset, you can safely ignore this email. + div(style='margin: 1rem 0 0; font-size: 1.25em;') + | A request has been received to change the password for your #{applicationTitle} account. + tr + td + a(href=resetPasswordLink style='display: block; margin: 1.5rem 3rem; text-decoration: none; font-size: 1.0em; line-height: 2.25em;') + span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);') + | Reset Password + tr + td(style='text-align: center;') + div(style='margin: 1rem 0 0; font-size: 1.25em;') + | The above link will expire in 24 hours. + tr + td(style='text-align: center;') + div(style='margin: 1rem 1rem 0; font-size: 1.25em;') + | If you did not initiate this request, you may safely disregard this message. \ No newline at end of file diff --git a/server/templates/email/test-email/html.pug b/server/templates/email/test-email/html.pug index 9dc9044c0..3ba83a936 100644 --- a/server/templates/email/test-email/html.pug +++ b/server/templates/email/test-email/html.pug @@ -6,25 +6,6 @@ head meta(name='viewport' content='width=device-width, initial-scale=1') meta(name='format-detection' content='telephone=no, date=no, address=no, email=no') link(href='https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' rel='stylesheet' media='screen') - //if mso - xml - o:officedocumentsettings - o:pixelsperinch 96 - style. - td, - th, - div, - p, - a, - h1, - h2, - h3, - h4, - h5, - h6 { - font-family: 'Segoe UI', sans-serif; - mso-line-height-rule: exactly; - } style. .title:hover * { text-decoration: underline; @@ -35,20 +16,28 @@ head width: 100% !important; } } -div(style='display: block; background-color: #111827;') - table(style='margin: 0 auto; font-family: Inter, Arial, Sans-Serif; color: #fff; font-size: 16px; width: 26rem;') +div(style='display: block; background-color: #111827; padding: 2.5rem 0;') + table(style='margin: 0 auto; font-family: Inter, Arial, sans-serif; color: #fff; font-size: 16px; width: 26rem;') tr td(style="text-align: center;") - a(href=applicationUrl) - img(src=applicationUrl +'/logo_full.png' style='width: 26rem; padding: 1rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;') + if applicationUrl + a(href=applicationUrl style='margin: 0 1rem;') + img(src=applicationUrl +'/logo_full.png' style='width: 26rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;') + else + div(style='margin: 0 1rem 2.5rem; font-size: 3em; font-weight: 700;') + | #{applicationTitle} + if recipientName !== recipientEmail + tr + td(style='text-align: center;') + div(style='margin: 1rem 0 0; font-size: 1.25em;') + | Hi, #{recipientName.replace(/\.|@/g, ((x) => x + '\ufeff'))}! tr td(style='text-align: center;') - div(style='margin: 0rem 1rem 1rem; font-size: 1.25em;') - span - | #{body} + div(style='margin: 1rem 0 0; font-size: 1.25em;') + | #{body} if applicationUrl tr td - a(href=applicationUrl style='display: block; margin: 1.5rem 3rem 2.5rem 3rem; text-decoration: none; font-size: 1.0em; line-height: 2.25em;') - span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255, 255, 255, 0.2);') + a(href=applicationUrl style='display: block; margin: 1.5rem 3rem 0; text-decoration: none; font-size: 1.0em; line-height: 2.25em;') + span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);') | Open #{applicationTitle} diff --git a/server/tsconfig.json b/server/tsconfig.json index eb4037039..d245100d9 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "target": "ES2019", - "lib": ["ES2019"], + "target": "ES2020", "module": "commonjs", "outDir": "../dist", "noEmit": false diff --git a/server/utils/asyncLock.ts b/server/utils/asyncLock.ts index 630f7df33..51794a989 100644 --- a/server/utils/asyncLock.ts +++ b/server/utils/asyncLock.ts @@ -40,7 +40,7 @@ class AsyncLock { public dispatch = async ( key: string | number, callback: () => Promise - ): Promise => { + ) => { const skey = String(key); await this.acquire(skey); try { diff --git a/server/utils/typeHelpers.ts b/server/utils/typeHelpers.ts index ca12ddf45..04070244b 100644 --- a/server/utils/typeHelpers.ts +++ b/server/utils/typeHelpers.ts @@ -1,7 +1,10 @@ import type { + TmdbMovieDetails, TmdbMovieResult, - TmdbTvResult, + TmdbPersonDetails, TmdbPersonResult, + TmdbTvDetails, + TmdbTvResult, } from '../api/themoviedb/interfaces'; export const isMovie = ( @@ -15,3 +18,15 @@ export const isPerson = ( ): person is TmdbPersonResult => { return (person as TmdbPersonResult).known_for !== undefined; }; + +export const isMovieDetails = ( + movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails +): movie is TmdbMovieDetails => { + return (movie as TmdbMovieDetails).title !== undefined; +}; + +export const isTvDetails = ( + tv: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails +): tv is TmdbTvDetails => { + return (tv as TmdbTvDetails).number_of_seasons !== undefined; +}; diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 2a1caee49..0a7099ccf 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,19 +1,19 @@ -name: jellyseerr -adopt-info: jellyseerr +name: overseerr +adopt-info: overseerr license: MIT summary: Request management and media discovery tool for the Plex ecosystem. description: > - Jellyseerr is a free and open source software application for managing requests for your media library. + Overseerr is a free and open source software application for managing requests for your media library. It integrates with your existing services such as Sonarr, Radarr and Plex! base: core18 confinement: strict parts: - jellyseerr: + overseerr: plugin: nodejs - nodejs-version: '14.17.0' + nodejs-version: '16.14.0' nodejs-package-manager: 'yarn' - nodejs-yarn-version: v1.22.10 + nodejs-yarn-version: v1.22.17 build-packages: - git - on arm64: @@ -88,7 +88,7 @@ apps: - network-bind environment: PATH: '$SNAP/usr/sbin:$SNAP/usr/bin:$SNAP/sbin:$SNAP/bin:$PATH' - JELLYSEERR_SNAP: 'True' + OVERSEERR_SNAP: 'True' CONFIG_DIRECTORY: $SNAP_USER_COMMON LOG_LEVEL: 'debug' NODE_ENV: 'production' diff --git a/src/assets/extlogos/gotify.svg b/src/assets/extlogos/gotify.svg new file mode 100644 index 000000000..6d0789924 --- /dev/null +++ b/src/assets/extlogos/gotify.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/services/trakt.svg b/src/assets/services/trakt.svg new file mode 100644 index 000000000..bf7e6fc46 --- /dev/null +++ b/src/assets/services/trakt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index 56b368d90..839f019ad 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -1,14 +1,11 @@ -import { DownloadIcon, DuplicateIcon } from '@heroicons/react/outline'; -import axios from 'axios'; +import { DownloadIcon } from '@heroicons/react/outline'; import { uniq } from 'lodash'; import Link from 'next/link'; import { useRouter } from 'next/router'; import React, { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; import { MediaStatus } from '../../../server/constants/media'; -import type { MediaRequest } from '../../../server/entity/MediaRequest'; import type { Collection } from '../../../server/models/Collection'; import useSettings from '../../hooks/useSettings'; import { Permission, useUser } from '../../hooks/useUser'; @@ -17,23 +14,17 @@ import Error from '../../pages/_error'; import ButtonWithDropdown from '../Common/ButtonWithDropdown'; import CachedImage from '../Common/CachedImage'; import LoadingSpinner from '../Common/LoadingSpinner'; -import Modal from '../Common/Modal'; import PageTitle from '../Common/PageTitle'; +import RequestModal from '../RequestModal'; import Slider from '../Slider'; import StatusBadge from '../StatusBadge'; import TitleCard from '../TitleCard'; -import Transition from '../Transition'; const messages = defineMessages({ overview: 'Overview', numberofmovies: '{count} Movies', requestcollection: 'Request Collection', - requestswillbecreated: - 'The following titles will have requests created for them:', requestcollection4k: 'Request Collection in 4K', - requestswillbecreated4k: - 'The following titles will have 4K requests created for them:', - requestSuccess: '{title} requested successfully!', }); interface CollectionDetailsProps { @@ -46,19 +37,18 @@ const CollectionDetails: React.FC = ({ const intl = useIntl(); const router = useRouter(); const settings = useSettings(); - const { addToast } = useToasts(); const { hasPermission } = useUser(); const [requestModal, setRequestModal] = useState(false); - const [isRequesting, setRequesting] = useState(false); const [is4k, setIs4k] = useState(false); - const { data, error, revalidate } = useSWR( - `/api/v1/collection/${router.query.collectionId}`, - { - initialData: collection, - revalidateOnMount: true, - } - ); + const { + data, + error, + mutate: revalidate, + } = useSWR(`/api/v1/collection/${router.query.collectionId}`, { + fallbackData: collection, + revalidateOnMount: true, + }); const { data: genres } = useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`); @@ -124,48 +114,6 @@ const CollectionDetails: React.FC = ({ !part.mediaInfo || part.mediaInfo.status4k === MediaStatus.UNKNOWN ).length > 0; - const requestableParts = data.parts.filter( - (part) => - !part.mediaInfo || - part.mediaInfo[is4k ? 'status4k' : 'status'] === MediaStatus.UNKNOWN - ); - - const requestBundle = async () => { - try { - setRequesting(true); - await Promise.all( - requestableParts.map(async (part) => { - await axios.post('/api/v1/request', { - mediaId: part.id, - mediaType: 'movie', - is4k, - }); - }) - ); - - addToast( - - {intl.formatMessage(messages.requestSuccess, { - title: data?.name, - strong: function strong(msg) { - return {msg}; - }, - })} - , - { appearance: 'success', autoDismiss: true } - ); - } catch (e) { - addToast('Something went wrong requesting the collection.', { - appearance: 'error', - autoDismiss: true, - }); - } finally { - setRequesting(false); - setRequestModal(false); - revalidate(); - } - }; - const collectionAttributes: React.ReactNode[] = []; collectionAttributes.push( @@ -229,53 +177,17 @@ const CollectionDetails: React.FC = ({ )} - - requestBundle()} - okText={ - isRequesting - ? intl.formatMessage(globalMessages.requesting) - : intl.formatMessage( - is4k ? globalMessages.request4k : globalMessages.request - ) - } - okDisabled={isRequesting} - okButtonType="primary" - onCancel={() => setRequestModal(false)} - title={intl.formatMessage( - is4k ? messages.requestcollection4k : messages.requestcollection - )} - iconSvg={} - > -

- {intl.formatMessage( - is4k - ? messages.requestswillbecreated4k - : messages.requestswillbecreated - )} -

-
    - {data.parts - .filter( - (part) => - !part.mediaInfo || - part.mediaInfo[is4k ? 'status4k' : 'status'] === - MediaStatus.UNKNOWN - ) - .map((part) => ( -
  • {part.title}
  • - ))} -
-
-
+ type="collection" + is4k={is4k} + onComplete={() => { + revalidate(); + setRequestModal(false); + }} + onCancel={() => setRequestModal(false)} + />
= ({ .map((t, k) => {t}) .reduce((prev, curr) => ( <> - {prev} | {curr} + {prev} + | + {curr} ))} diff --git a/src/components/Common/Accordion/index.tsx b/src/components/Common/Accordion/index.tsx index e4a02da98..67e883fe0 100644 --- a/src/components/Common/Accordion/index.tsx +++ b/src/components/Common/Accordion/index.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import AnimateHeight from 'react-animate-height'; export interface AccordionProps { - children: (args: AccordionChildProps) => React.ReactElement | null; + children: (args: AccordionChildProps) => React.ReactElement | null; /** If true, only one accordion item can be open at any time */ single?: boolean; /** If true, at least one accordion item will always be open */ @@ -13,7 +13,7 @@ export interface AccordionProps { export interface AccordionChildProps { openIndexes: number[]; handleClick(index: number): void; - AccordionContent: typeof AccordionContent; + AccordionContent: any; } export const AccordionContent: React.FC<{ isOpen: boolean }> = ({ diff --git a/src/components/Common/Alert/index.tsx b/src/components/Common/Alert/index.tsx index cddcf992d..e9789c706 100644 --- a/src/components/Common/Alert/index.tsx +++ b/src/components/Common/Alert/index.tsx @@ -15,7 +15,7 @@ const Alert: React.FC = ({ title, children, type }) => { bgColor: 'bg-yellow-600', titleColor: 'text-yellow-100', textColor: 'text-yellow-300', - svg: , + svg: , }; switch (type) { @@ -24,7 +24,7 @@ const Alert: React.FC = ({ title, children, type }) => { bgColor: 'bg-indigo-600', titleColor: 'text-indigo-100', textColor: 'text-indigo-300', - svg: , + svg: , }; break; case 'error': @@ -32,13 +32,13 @@ const Alert: React.FC = ({ title, children, type }) => { bgColor: 'bg-red-600', titleColor: 'text-red-100', textColor: 'text-red-300', - svg: , + svg: , }; break; } return ( -
+
{design.svg}
@@ -48,7 +48,7 @@ const Alert: React.FC = ({ title, children, type }) => {
)} {children && ( -
+
{children}
)} diff --git a/src/components/Common/Badge/index.tsx b/src/components/Common/Badge/index.tsx index 9118415f2..33e55ab72 100644 --- a/src/components/Common/Badge/index.tsx +++ b/src/components/Common/Badge/index.tsx @@ -1,38 +1,78 @@ +import Link from 'next/link'; import React from 'react'; interface BadgeProps { badgeType?: 'default' | 'primary' | 'danger' | 'warning' | 'success'; className?: string; + href?: string; } const Badge: React.FC = ({ badgeType = 'default', className, + href, children, }) => { const badgeStyle = [ - 'px-2 inline-flex text-xs leading-5 font-semibold rounded-full cursor-default', + 'px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap', ]; + if (href) { + badgeStyle.push('transition cursor-pointer !no-underline'); + } else { + badgeStyle.push('cursor-default'); + } + switch (badgeType) { case 'danger': - badgeStyle.push('bg-red-600 text-red-100'); + badgeStyle.push('bg-red-600 !text-red-100'); + if (href) { + badgeStyle.push('hover:bg-red-500'); + } break; case 'warning': - badgeStyle.push('bg-yellow-500 text-yellow-100'); + badgeStyle.push('bg-yellow-500 !text-yellow-100'); + if (href) { + badgeStyle.push('hover:bg-yellow-400'); + } break; case 'success': - badgeStyle.push('bg-green-500 text-green-100'); + badgeStyle.push('bg-green-500 !text-green-100'); + if (href) { + badgeStyle.push('hover:bg-green-400'); + } break; default: - badgeStyle.push('bg-indigo-500 text-indigo-100'); + badgeStyle.push('bg-indigo-500 !text-indigo-100'); + if (href) { + badgeStyle.push('hover:bg-indigo-400'); + } } if (className) { badgeStyle.push(className); } - return {children}; + if (href?.includes('://')) { + return ( + + {children} + + ); + } else if (href) { + return ( + + {children} + + ); + } else { + return {children}; + } }; export default Badge; diff --git a/src/components/Common/ButtonWithDropdown/index.tsx b/src/components/Common/ButtonWithDropdown/index.tsx index 944c9d8bb..6edb4a11f 100644 --- a/src/components/Common/ButtonWithDropdown/index.tsx +++ b/src/components/Common/ButtonWithDropdown/index.tsx @@ -32,7 +32,7 @@ const DropdownItem: React.FC = ({ } return ( {children} @@ -84,7 +84,7 @@ const ButtonWithDropdown: React.FC = ({ {children && ( - +
diff --git a/src/components/Common/Table/index.tsx b/src/components/Common/Table/index.tsx index 65b1549f7..9e0cb0ca5 100644 --- a/src/components/Common/Table/index.tsx +++ b/src/components/Common/Table/index.tsx @@ -3,7 +3,7 @@ import { withProperties } from '../../../utils/typeHelpers'; const TBody: React.FC = ({ children }) => { return ( - {children} + {children} ); }; diff --git a/src/components/CompanyCard/index.tsx b/src/components/CompanyCard/index.tsx index 396f6e85e..b6383a77a 100644 --- a/src/components/CompanyCard/index.tsx +++ b/src/components/CompanyCard/index.tsx @@ -13,10 +13,10 @@ const CompanyCard: React.FC = ({ image, url, name }) => { return ( { setHovered(true); @@ -33,10 +33,10 @@ const CompanyCard: React.FC = ({ image, url, name }) => { {name}
diff --git a/src/components/Discover/DiscoverNetwork/index.tsx b/src/components/Discover/DiscoverNetwork/index.tsx index c2e96421b..247c5ece7 100644 --- a/src/components/Discover/DiscoverNetwork/index.tsx +++ b/src/components/Discover/DiscoverNetwork/index.tsx @@ -47,7 +47,7 @@ const DiscoverTvNetwork: React.FC = () => {
{firstResultData?.network.logoPath ? ( -
+
{firstResultData.network.name} {
{firstResultData?.studio.logoPath ? ( -
+
{firstResultData.studio.name} = ({ return (
-
+
{downloadItem.title}
-
+
= ({ }%`, }} /> -
+
{downloadItem.size ? Math.round( diff --git a/src/components/ExternalLinkBlock/index.tsx b/src/components/ExternalLinkBlock/index.tsx index e8f863ba4..af49fda99 100644 --- a/src/components/ExternalLinkBlock/index.tsx +++ b/src/components/ExternalLinkBlock/index.tsx @@ -6,6 +6,7 @@ import JellyfinLogo from '../../assets/services/jellyfin.svg'; import PlexLogo from '../../assets/services/plex.svg'; import RTLogo from '../../assets/services/rt.svg'; import TmdbLogo from '../../assets/services/tmdb.svg'; +import TraktLogo from '../../assets/services/trakt.svg'; import TvdbLogo from '../../assets/services/tvdb.svg'; import useLocale from '../../hooks/useLocale'; import useSettings from '../../hooks/useSettings'; @@ -31,15 +32,11 @@ const ExternalLinkBlock: React.FC = ({ const { locale } = useLocale(); return ( -
+ ); }; diff --git a/src/components/GenreCard/index.tsx b/src/components/GenreCard/index.tsx index fad201a5c..85d888a33 100644 --- a/src/components/GenreCard/index.tsx +++ b/src/components/GenreCard/index.tsx @@ -21,13 +21,13 @@ const GenreCard: React.FC = ({ return ( { setHovered(true); }} @@ -42,11 +42,11 @@ const GenreCard: React.FC = ({ >
-
+
{name}
@@ -57,7 +57,7 @@ const GenreCard: React.FC = ({ const GenreCardPlaceholder: React.FC = () => { return (
); }; diff --git a/src/components/IssueBlock/index.tsx b/src/components/IssueBlock/index.tsx new file mode 100644 index 000000000..3367a2c27 --- /dev/null +++ b/src/components/IssueBlock/index.tsx @@ -0,0 +1,79 @@ +import { + CalendarIcon, + ExclamationIcon, + EyeIcon, + UserIcon, +} from '@heroicons/react/solid'; +import Link from 'next/link'; +import React from 'react'; +import { useIntl } from 'react-intl'; +import type Issue from '../../../server/entity/Issue'; +import { useUser } from '../../hooks/useUser'; +import Button from '../Common/Button'; +import { issueOptions } from '../IssueModal/constants'; + +interface IssueBlockProps { + issue: Issue; +} + +const IssueBlock: React.FC = ({ issue }) => { + const { user } = useUser(); + const intl = useIntl(); + const issueOption = issueOptions.find( + (opt) => opt.issueType === issue.issueType + ); + + if (!issueOption) { + return null; + } + + return ( +
+
+
+
+ + + {intl.formatMessage(issueOption.name)} + +
+ +
+ + + {intl.formatDate(issue.createdAt, { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + +
+
+
+ + + +
+
+
+ ); +}; + +export default IssueBlock; diff --git a/src/components/IssueDetails/IssueComment/index.tsx b/src/components/IssueDetails/IssueComment/index.tsx new file mode 100644 index 000000000..99b26a44a --- /dev/null +++ b/src/components/IssueDetails/IssueComment/index.tsx @@ -0,0 +1,269 @@ +import { Menu } from '@headlessui/react'; +import { ExclamationIcon } from '@heroicons/react/outline'; +import { DotsVerticalIcon } from '@heroicons/react/solid'; +import axios from 'axios'; +import { Field, Form, Formik } from 'formik'; +import Link from 'next/link'; +import React, { useState } from 'react'; +import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; +import ReactMarkdown from 'react-markdown'; +import * as Yup from 'yup'; +import type { default as IssueCommentType } from '../../../../server/entity/IssueComment'; +import { Permission, useUser } from '../../../hooks/useUser'; +import Button from '../../Common/Button'; +import Modal from '../../Common/Modal'; +import Transition from '../../Transition'; + +const messages = defineMessages({ + postedby: 'Posted {relativeTime} by {username}', + postedbyedited: 'Posted {relativeTime} by {username} (Edited)', + delete: 'Delete Comment', + areyousuredelete: 'Are you sure you want to delete this comment?', + validationComment: 'You must enter a message', + edit: 'Edit Comment', +}); + +interface IssueCommentProps { + comment: IssueCommentType; + isReversed?: boolean; + isActiveUser?: boolean; + onUpdate?: () => void; +} + +const IssueComment: React.FC = ({ + comment, + isReversed = false, + isActiveUser = false, + onUpdate, +}) => { + const intl = useIntl(); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const { hasPermission } = useUser(); + + const EditCommentSchema = Yup.object().shape({ + newMessage: Yup.string().required( + intl.formatMessage(messages.validationComment) + ), + }); + + const deleteComment = async () => { + try { + await axios.delete(`/api/v1/issueComment/${comment.id}`); + } catch (e) { + // something went wrong deleting the comment + } finally { + if (onUpdate) { + onUpdate(); + } + } + }; + + return ( +
+ + setShowDeleteModal(false)} + onOk={() => deleteComment()} + okText={intl.formatMessage(messages.delete)} + okButtonType="danger" + iconSvg={} + > + {intl.formatMessage(messages.areyousuredelete)} + + + + + + + +
+
+ {(isActiveUser || hasPermission(Permission.MANAGE_ISSUES)) && ( + + {({ open }) => ( + <> +
+ + Open options + +
+ + + +
+ {isActiveUser && ( + + {({ active }) => ( + + )} + + )} + + {({ active }) => ( + + )} + +
+
+
+ + )} +
+ )} +
+
+ {isEditing ? ( + { + await axios.put(`/api/v1/issueComment/${comment.id}`, { + message: values.newMessage, + }); + + if (onUpdate) { + onUpdate(); + } + + setIsEditing(false); + }} + validationSchema={EditCommentSchema} + > + {({ isValid, isSubmitting, errors, touched }) => { + return ( +
+ + {errors.newMessage && touched.newMessage && ( +
{errors.newMessage}
+ )} +
+ + +
+ + ); + }} +
+ ) : ( +
+ + {comment.message} + +
+ )} +
+
+
+ + {intl.formatMessage( + comment.createdAt !== comment.updatedAt + ? messages.postedbyedited + : messages.postedby, + { + username: ( + + + {comment.user.displayName} + + + ), + relativeTime: ( + + ), + } + )} + +
+
+
+ ); +}; + +export default IssueComment; diff --git a/src/components/IssueDetails/IssueDescription/index.tsx b/src/components/IssueDetails/IssueDescription/index.tsx new file mode 100644 index 000000000..103626d47 --- /dev/null +++ b/src/components/IssueDetails/IssueDescription/index.tsx @@ -0,0 +1,157 @@ +import { Menu, Transition } from '@headlessui/react'; +import { DotsVerticalIcon } from '@heroicons/react/solid'; +import { Field, Form, Formik } from 'formik'; +import React, { Fragment, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import ReactMarkdown from 'react-markdown'; +import { Permission, useUser } from '../../../hooks/useUser'; +import globalMessages from '../../../i18n/globalMessages'; +import Button from '../../Common/Button'; + +const messages = defineMessages({ + description: 'Description', + edit: 'Edit Description', + deleteissue: 'Delete Issue', +}); + +interface IssueDescriptionProps { + description: string; + belongsToUser: boolean; + commentCount: number; + onEdit: (newDescription: string) => void; + onDelete: () => void; +} + +const IssueDescription: React.FC = ({ + description, + belongsToUser, + commentCount, + onEdit, + onDelete, +}) => { + const intl = useIntl(); + const { hasPermission } = useUser(); + const [isEditing, setIsEditing] = useState(false); + + return ( +
+
+
+ {intl.formatMessage(messages.description)} +
+ {(hasPermission(Permission.MANAGE_ISSUES) || belongsToUser) && ( + + {({ open }) => ( + <> +
+ + Open options + +
+ + + +
+ {belongsToUser && ( + + {({ active }) => ( + + )} + + )} + {(hasPermission(Permission.MANAGE_ISSUES) || + !commentCount) && ( + + {({ active }) => ( + + )} + + )} +
+
+
+ + )} +
+ )} +
+ {isEditing ? ( + { + onEdit(values.newMessage); + setIsEditing(false); + }} + > + {() => { + return ( +
+ +
+ + +
+ + ); + }} +
+ ) : ( +
+ + {description} + +
+ )} +
+ ); +}; + +export default IssueDescription; diff --git a/src/components/IssueDetails/index.tsx b/src/components/IssueDetails/index.tsx new file mode 100644 index 000000000..90cfb494c --- /dev/null +++ b/src/components/IssueDetails/index.tsx @@ -0,0 +1,699 @@ +import { + ChatIcon, + CheckCircleIcon, + ExclamationIcon, + PlayIcon, + ServerIcon, +} from '@heroicons/react/outline'; +import { RefreshIcon } from '@heroicons/react/solid'; +import axios from 'axios'; +import { Field, Form, Formik } from 'formik'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import React, { useState } from 'react'; +import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; +import * as Yup from 'yup'; +import { IssueStatus } from '../../../server/constants/issue'; +import { MediaType } from '../../../server/constants/media'; +import type Issue from '../../../server/entity/Issue'; +import type { MovieDetails } from '../../../server/models/Movie'; +import type { TvDetails } from '../../../server/models/Tv'; +import { Permission, useUser } from '../../hooks/useUser'; +import globalMessages from '../../i18n/globalMessages'; +import Error from '../../pages/_error'; +import Badge from '../Common/Badge'; +import Button from '../Common/Button'; +import CachedImage from '../Common/CachedImage'; +import LoadingSpinner from '../Common/LoadingSpinner'; +import Modal from '../Common/Modal'; +import PageTitle from '../Common/PageTitle'; +import { issueOptions } from '../IssueModal/constants'; +import Transition from '../Transition'; +import IssueComment from './IssueComment'; +import IssueDescription from './IssueDescription'; +import { MediaServerType } from '../../../server/constants/server'; +import useSettings from '../../hooks/useSettings'; + +const messages = defineMessages({ + openedby: '#{issueId} opened {relativeTime} by {username}', + closeissue: 'Close Issue', + closeissueandcomment: 'Close with Comment', + leavecomment: 'Comment', + comments: 'Comments', + reopenissue: 'Reopen Issue', + reopenissueandcomment: 'Reopen with Comment', + issuepagetitle: 'Issue', + playonplex: 'Play on {mediaServerName}', + play4konplex: 'Play in 4K on {mediaServerName}', + openinarr: 'Open in {arr}', + openin4karr: 'Open in 4K {arr}', + toasteditdescriptionsuccess: 'Issue description edited successfully!', + toasteditdescriptionfailed: + 'Something went wrong while editing the issue description.', + toaststatusupdated: 'Issue status updated successfully!', + toaststatusupdatefailed: + 'Something went wrong while updating the issue status.', + issuetype: 'Type', + lastupdated: 'Last Updated', + problemseason: 'Affected Season', + allseasons: 'All Seasons', + season: 'Season {seasonNumber}', + problemepisode: 'Affected Episode', + allepisodes: 'All Episodes', + episode: 'Episode {episodeNumber}', + deleteissue: 'Delete Issue', + deleteissueconfirm: 'Are you sure you want to delete this issue?', + toastissuedeleted: 'Issue deleted successfully!', + toastissuedeletefailed: 'Something went wrong while deleting the issue.', + nocomments: 'No comments.', + unknownissuetype: 'Unknown', + commentplaceholder: 'Add a comment…', +}); + +const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { + return (movie as MovieDetails).title !== undefined; +}; + +const IssueDetails: React.FC = () => { + const { addToast } = useToasts(); + const router = useRouter(); + const intl = useIntl(); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const { user: currentUser, hasPermission } = useUser(); + const { data: issueData, mutate: revalidateIssue } = useSWR( + `/api/v1/issue/${router.query.issueId}` + ); + const { data, error } = useSWR( + issueData?.media.tmdbId + ? `/api/v1/${issueData.media.mediaType}/${issueData.media.tmdbId}` + : null + ); + + const CommentSchema = Yup.object().shape({ + message: Yup.string().required(), + }); + + const issueOption = issueOptions.find( + (opt) => opt.issueType === issueData?.issueType + ); + const settings = useSettings(); + + if (!data && !error) { + return ; + } + + if (!data || !issueData) { + return ; + } + + const belongsToUser = issueData.createdBy.id === currentUser?.id; + + const [firstComment, ...otherComments] = issueData.comments; + + const editFirstComment = async (newMessage: string) => { + try { + await axios.put(`/api/v1/issueComment/${firstComment.id}`, { + message: newMessage, + }); + + addToast(intl.formatMessage(messages.toasteditdescriptionsuccess), { + appearance: 'success', + autoDismiss: true, + }); + revalidateIssue(); + } catch (e) { + addToast(intl.formatMessage(messages.toasteditdescriptionfailed), { + appearance: 'error', + autoDismiss: true, + }); + } + }; + + const updateIssueStatus = async (newStatus: 'open' | 'resolved') => { + try { + await axios.post(`/api/v1/issue/${issueData.id}/${newStatus}`); + + addToast(intl.formatMessage(messages.toaststatusupdated), { + appearance: 'success', + autoDismiss: true, + }); + revalidateIssue(); + } catch (e) { + addToast(intl.formatMessage(messages.toaststatusupdatefailed), { + appearance: 'error', + autoDismiss: true, + }); + } + }; + + const deleteIssue = async () => { + try { + await axios.delete(`/api/v1/issue/${issueData.id}`); + + addToast(intl.formatMessage(messages.toastissuedeleted), { + appearance: 'success', + autoDismiss: true, + }); + router.push('/issues'); + } catch (e) { + addToast(intl.formatMessage(messages.toastissuedeletefailed), { + appearance: 'error', + autoDismiss: true, + }); + } + }; + + const title = isMovie(data) ? data.title : data.name; + const releaseYear = isMovie(data) ? data.releaseDate : data.firstAirDate; + + return ( +
+ + + setShowDeleteModal(false)} + onOk={() => deleteIssue()} + okText={intl.formatMessage(messages.deleteissue)} + okButtonType="danger" + iconSvg={} + > + {intl.formatMessage(messages.deleteissueconfirm)} + + + {data.backdropPath && ( +
+ +
+
+ )} +
+
+ +
+
+
+ {issueData.status === IssueStatus.OPEN && ( + + {intl.formatMessage(globalMessages.open)} + + )} + {issueData.status === IssueStatus.RESOLVED && ( + + {intl.formatMessage(globalMessages.resolved)} + + )} +
+

+ + {title} + {' '} + {releaseYear && ( + ({releaseYear.slice(0, 4)}) + )} +

+ + {intl.formatMessage(messages.openedby, { + issueId: issueData.id, + username: ( + + + + + {issueData.createdBy.displayName} + + + + ), + relativeTime: ( + + ), + })} + +
+
+
+
+ { + editFirstComment(newMessage); + }} + onDelete={() => setShowDeleteModal(true)} + /> +
+
+
+ {intl.formatMessage(messages.issuetype)} + + {intl.formatMessage( + issueOption?.name ?? messages.unknownissuetype + )} + +
+ {issueData.media.mediaType === MediaType.TV && ( + <> +
+ {intl.formatMessage(messages.problemseason)} + + {intl.formatMessage( + issueData.problemSeason > 0 + ? messages.season + : messages.allseasons, + { seasonNumber: issueData.problemSeason } + )} + +
+ {issueData.problemSeason > 0 && ( +
+ {intl.formatMessage(messages.problemepisode)} + + {intl.formatMessage( + issueData.problemEpisode > 0 + ? messages.episode + : messages.allepisodes, + { episodeNumber: issueData.problemEpisode } + )} + +
+ )} + + )} +
+ {intl.formatMessage(messages.lastupdated)} + + + +
+
+
+ {issueData?.media.mediaUrl && ( + + )} + {issueData?.media.serviceUrl && hasPermission(Permission.ADMIN) && ( + + )} + {issueData?.media.mediaUrl4k && ( + + )} + {issueData?.media.serviceUrl4k && + hasPermission(Permission.ADMIN) && ( + + )} +
+
+
+
+ {intl.formatMessage(messages.comments)} +
+ {otherComments.map((comment) => ( + revalidateIssue()} + /> + ))} + {otherComments.length === 0 && ( +
+ {intl.formatMessage(messages.nocomments)} +
+ )} + {(hasPermission(Permission.MANAGE_ISSUES) || belongsToUser) && ( + { + await axios.post(`/api/v1/issue/${issueData?.id}/comment`, { + message: values.message, + }); + revalidateIssue(); + resetForm(); + }} + > + {({ isValid, isSubmitting, values, handleSubmit }) => { + return ( +
+
+ +
+ {hasPermission(Permission.MANAGE_ISSUES) && ( + <> + {issueData.status === IssueStatus.OPEN ? ( + + ) : ( + + )} + + )} + +
+
+
+ ); + }} +
+ )} +
+
+
+
+
+ {intl.formatMessage(messages.issuetype)} + + {intl.formatMessage( + issueOption?.name ?? messages.unknownissuetype + )} + +
+ {issueData.media.mediaType === MediaType.TV && ( + <> +
+ {intl.formatMessage(messages.problemseason)} + + {intl.formatMessage( + issueData.problemSeason > 0 + ? messages.season + : messages.allseasons, + { seasonNumber: issueData.problemSeason } + )} + +
+ {issueData.problemSeason > 0 && ( +
+ {intl.formatMessage(messages.problemepisode)} + + {intl.formatMessage( + issueData.problemEpisode > 0 + ? messages.episode + : messages.allepisodes, + { episodeNumber: issueData.problemEpisode } + )} + +
+ )} + + )} +
+ {intl.formatMessage(messages.lastupdated)} + + + +
+
+
+ {issueData?.media.mediaUrl && ( + + )} + {issueData?.media.serviceUrl && hasPermission(Permission.ADMIN) && ( + + )} + {issueData?.media.mediaUrl4k && ( + + )} + {issueData?.media.serviceUrl4k && hasPermission(Permission.ADMIN) && ( + + )} +
+
+
+
+ ); +}; + +export default IssueDetails; diff --git a/src/components/IssueList/IssueItem/index.tsx b/src/components/IssueList/IssueItem/index.tsx new file mode 100644 index 000000000..1695215a3 --- /dev/null +++ b/src/components/IssueList/IssueItem/index.tsx @@ -0,0 +1,275 @@ +import { EyeIcon } from '@heroicons/react/solid'; +import Link from 'next/link'; +import React from 'react'; +import { useInView } from 'react-intersection-observer'; +import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; +import useSWR from 'swr'; +import { IssueStatus } from '../../../../server/constants/issue'; +import { MediaType } from '../../../../server/constants/media'; +import Issue from '../../../../server/entity/Issue'; +import { MovieDetails } from '../../../../server/models/Movie'; +import { TvDetails } from '../../../../server/models/Tv'; +import { Permission, useUser } from '../../../hooks/useUser'; +import globalMessages from '../../../i18n/globalMessages'; +import Badge from '../../Common/Badge'; +import Button from '../../Common/Button'; +import CachedImage from '../../Common/CachedImage'; +import { issueOptions } from '../../IssueModal/constants'; + +const messages = defineMessages({ + openeduserdate: '{date} by {user}', + seasons: '{seasonCount, plural, one {Season} other {Seasons}}', + episodes: '{episodeCount, plural, one {Episode} other {Episodes}}', + problemepisode: 'Affected Episode', + issuetype: 'Type', + issuestatus: 'Status', + opened: 'Opened', + viewissue: 'View Issue', + unknownissuetype: 'Unknown', +}); + +const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { + return (movie as MovieDetails).title !== undefined; +}; + +interface IssueItemProps { + issue: Issue; +} + +const IssueItem: React.FC = ({ issue }) => { + const intl = useIntl(); + const { hasPermission } = useUser(); + const { ref, inView } = useInView({ + triggerOnce: true, + }); + const url = + issue.media.mediaType === 'movie' + ? `/api/v1/movie/${issue.media.tmdbId}` + : `/api/v1/tv/${issue.media.tmdbId}`; + const { data: title, error } = useSWR( + inView ? url : null + ); + + if (!title && !error) { + return ( +
+ ); + } + + if (!title) { + return
uh oh
; + } + + const issueOption = issueOptions.find( + (opt) => opt.issueType === issue?.issueType + ); + + const problemSeasonEpisodeLine: React.ReactNode[] = []; + + if (!isMovie(title) && issue) { + problemSeasonEpisodeLine.push( + <> + + {intl.formatMessage(messages.seasons, { + seasonCount: issue.problemSeason ? 1 : 0, + })} + + + + {issue.problemSeason > 0 + ? issue.problemSeason + : intl.formatMessage(globalMessages.all)} + + + + ); + + if (issue.problemSeason > 0) { + problemSeasonEpisodeLine.push( + <> + + {intl.formatMessage(messages.episodes, { + episodeCount: issue.problemEpisode ? 1 : 0, + })} + + + + {issue.problemEpisode > 0 + ? issue.problemEpisode + : intl.formatMessage(globalMessages.all)} + + + + ); + } + } + + return ( +
+ {title.backdropPath && ( +
+ +
+
+ )} +
+
+ + + + + +
+
+ {(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice( + 0, + 4 + )} +
+ + + {isMovie(title) ? title.title : title.name} + + + {problemSeasonEpisodeLine.length > 0 && ( +
+ {problemSeasonEpisodeLine.map((t, k) => ( + {t} + ))} +
+ )} +
+
+
+
+ + {intl.formatMessage(messages.issuestatus)} + + {issue.status === IssueStatus.OPEN ? ( + + {intl.formatMessage(globalMessages.open)} + + ) : ( + + {intl.formatMessage(globalMessages.resolved)} + + )} +
+
+ + {intl.formatMessage(messages.issuetype)} + + + {intl.formatMessage( + issueOption?.name ?? messages.unknownissuetype + )} + +
+
+ {hasPermission([Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], { + type: 'or', + }) ? ( + <> + + {intl.formatMessage(messages.opened)} + + + {intl.formatMessage(messages.openeduserdate, { + date: ( + + ), + user: ( + + + + + {issue.createdBy.displayName} + + + + ), + })} + + + ) : ( + <> + + {intl.formatMessage(messages.opened)} + + + + + + )} +
+
+
+
+ + + + + +
+
+ ); +}; + +export default IssueItem; diff --git a/src/components/IssueList/index.tsx b/src/components/IssueList/index.tsx new file mode 100644 index 000000000..91e9a8eb4 --- /dev/null +++ b/src/components/IssueList/index.tsx @@ -0,0 +1,256 @@ +import { + ChevronLeftIcon, + ChevronRightIcon, + FilterIcon, + SortDescendingIcon, +} from '@heroicons/react/solid'; +import { useRouter } from 'next/router'; +import React, { useEffect, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import useSWR from 'swr'; +import { IssueResultsResponse } from '../../../server/interfaces/api/issueInterfaces'; +import Button from '../../components/Common/Button'; +import { useUpdateQueryParams } from '../../hooks/useUpdateQueryParams'; +import globalMessages from '../../i18n/globalMessages'; +import Header from '../Common/Header'; +import LoadingSpinner from '../Common/LoadingSpinner'; +import PageTitle from '../Common/PageTitle'; +import IssueItem from './IssueItem'; + +const messages = defineMessages({ + issues: 'Issues', + sortAdded: 'Most Recent', + sortModified: 'Last Modified', + showallissues: 'Show All Issues', +}); + +enum Filter { + ALL = 'all', + OPEN = 'open', + RESOLVED = 'resolved', +} + +type Sort = 'added' | 'modified'; + +const IssueList: React.FC = () => { + const intl = useIntl(); + const router = useRouter(); + const [currentFilter, setCurrentFilter] = useState(Filter.OPEN); + const [currentSort, setCurrentSort] = useState('added'); + const [currentPageSize, setCurrentPageSize] = useState(10); + + const page = router.query.page ? Number(router.query.page) : 1; + const pageIndex = page - 1; + const updateQueryParams = useUpdateQueryParams({ page: page.toString() }); + + const { data, error } = useSWR( + `/api/v1/issue?take=${currentPageSize}&skip=${ + pageIndex * currentPageSize + }&filter=${currentFilter}&sort=${currentSort}` + ); + + // Restore last set filter values on component mount + useEffect(() => { + const filterString = window.localStorage.getItem('il-filter-settings'); + + if (filterString) { + const filterSettings = JSON.parse(filterString); + + setCurrentFilter(filterSettings.currentFilter); + setCurrentSort(filterSettings.currentSort); + setCurrentPageSize(filterSettings.currentPageSize); + } + + // If filter value is provided in query, use that instead + if (Object.values(Filter).includes(router.query.filter as Filter)) { + setCurrentFilter(router.query.filter as Filter); + } + }, [router.query.filter]); + + // Set filter values to local storage any time they are changed + useEffect(() => { + window.localStorage.setItem( + 'il-filter-settings', + JSON.stringify({ + currentFilter, + currentSort, + currentPageSize, + }) + ); + }, [currentFilter, currentSort, currentPageSize]); + + if (!data && !error) { + return ; + } + + if (!data) { + return ; + } + + const hasNextPage = data.pageInfo.pages > pageIndex + 1; + const hasPrevPage = pageIndex > 0; + + return ( + <> + +
+
{intl.formatMessage(messages.issues)}
+
+
+ + + + +
+
+ + + + +
+
+
+ {data.results.map((issue) => { + return ( +
+ +
+ ); + })} + {data.results.length === 0 && ( +
+ + {intl.formatMessage(globalMessages.noresults)} + + {currentFilter !== Filter.ALL && ( +
+ +
+ )} +
+ )} +
+ +
+ + ); +}; + +export default IssueList; diff --git a/src/components/IssueModal/CreateIssueModal/index.tsx b/src/components/IssueModal/CreateIssueModal/index.tsx new file mode 100644 index 000000000..5dbc41802 --- /dev/null +++ b/src/components/IssueModal/CreateIssueModal/index.tsx @@ -0,0 +1,329 @@ +import { RadioGroup } from '@headlessui/react'; +import { ExclamationIcon } from '@heroicons/react/outline'; +import { ArrowCircleRightIcon } from '@heroicons/react/solid'; +import axios from 'axios'; +import { Field, Formik } from 'formik'; +import Link from 'next/link'; +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; +import * as Yup from 'yup'; +import { MediaStatus } from '../../../../server/constants/media'; +import type Issue from '../../../../server/entity/Issue'; +import { MovieDetails } from '../../../../server/models/Movie'; +import { TvDetails } from '../../../../server/models/Tv'; +import useSettings from '../../../hooks/useSettings'; +import { Permission, useUser } from '../../../hooks/useUser'; +import globalMessages from '../../../i18n/globalMessages'; +import Button from '../../Common/Button'; +import Modal from '../../Common/Modal'; +import { issueOptions } from '../constants'; + +const messages = defineMessages({ + validationMessageRequired: 'You must provide a description', + issomethingwrong: 'Is there a problem with {title}?', + whatswrong: "What's wrong?", + providedetail: + 'Please provide a detailed explanation of the issue you encountered.', + extras: 'Extras', + season: 'Season {seasonNumber}', + episode: 'Episode {episodeNumber}', + allseasons: 'All Seasons', + allepisodes: 'All Episodes', + problemseason: 'Affected Season', + problemepisode: 'Affected Episode', + toastSuccessCreate: + 'Issue report for {title} submitted successfully!', + toastFailedCreate: 'Something went wrong while submitting the issue.', + toastviewissue: 'View Issue', + reportissue: 'Report an Issue', + submitissue: 'Submit Issue', +}); + +const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { + return (movie as MovieDetails).title !== undefined; +}; + +const classNames = (...classes: string[]) => { + return classes.filter(Boolean).join(' '); +}; + +interface CreateIssueModalProps { + mediaType: 'movie' | 'tv'; + tmdbId?: number; + onCancel?: () => void; +} + +const CreateIssueModal: React.FC = ({ + onCancel, + mediaType, + tmdbId, +}) => { + const intl = useIntl(); + const settings = useSettings(); + const { hasPermission } = useUser(); + const { addToast } = useToasts(); + const { data, error } = useSWR( + tmdbId ? `/api/v1/${mediaType}/${tmdbId}` : null + ); + + if (!tmdbId) { + return null; + } + + const availableSeasons = (data?.mediaInfo?.seasons ?? []) + .filter( + (season) => + season.status === MediaStatus.AVAILABLE || + season.status === MediaStatus.PARTIALLY_AVAILABLE || + (settings.currentSettings.series4kEnabled && + hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], { + type: 'or', + }) && + (season.status4k === MediaStatus.AVAILABLE || + season.status4k === MediaStatus.PARTIALLY_AVAILABLE)) + ) + .map((season) => season.seasonNumber); + + const CreateIssueModalSchema = Yup.object().shape({ + message: Yup.string().required( + intl.formatMessage(messages.validationMessageRequired) + ), + }); + + return ( + { + try { + const newIssue = await axios.post('/api/v1/issue', { + issueType: values.selectedIssue.issueType, + message: values.message, + mediaId: data?.mediaInfo?.id, + problemSeason: values.problemSeason, + problemEpisode: + values.problemSeason > 0 ? values.problemEpisode : 0, + }); + + if (data) { + addToast( + <> +
+ {intl.formatMessage(messages.toastSuccessCreate, { + title: isMovie(data) ? data.title : data.name, + strong: function strong(msg) { + return {msg}; + }, + })} +
+ + + + , + { + appearance: 'success', + autoDismiss: true, + } + ); + } + + if (onCancel) { + onCancel(); + } + } catch (e) { + addToast(intl.formatMessage(messages.toastFailedCreate), { + appearance: 'error', + autoDismiss: true, + }); + } + }} + > + {({ handleSubmit, values, setFieldValue, errors, touched }) => { + return ( + } + title={intl.formatMessage(messages.reportissue)} + cancelText={intl.formatMessage(globalMessages.close)} + onOk={() => handleSubmit()} + okText={intl.formatMessage(messages.submitissue)} + loading={!data && !error} + backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`} + > + {data && ( +
+ + {intl.formatMessage(messages.issomethingwrong, { + title: isMovie(data) ? data.title : data.name, + })} + +
+ )} + {mediaType === 'tv' && data && !isMovie(data) && ( + <> +
+ +
+
+ + {availableSeasons.length > 1 && ( + + )} + {availableSeasons.map((season) => ( + + ))} + +
+
+
+ {values.problemSeason > 0 && ( +
+ +
+
+ + + {[ + ...Array( + data.seasons.find( + (season) => + Number(values.problemSeason) === + season.seasonNumber + )?.episodeCount ?? 0 + ), + ].map((i, index) => ( + + ))} + +
+
+
+ )} + + )} + setFieldValue('selectedIssue', issue)} + className="mt-4" + > + + Select an Issue + +
+ {issueOptions.map((setting, index) => ( + + classNames( + index === 0 ? 'rounded-tl-md rounded-tr-md' : '', + index === issueOptions.length - 1 + ? 'rounded-bl-md rounded-br-md' + : '', + checked + ? 'z-10 border-indigo-500 bg-indigo-600' + : 'border-gray-500', + 'relative flex cursor-pointer border p-4 focus:outline-none' + ) + } + > + {({ active, checked }) => ( + <> + + ))} +
+
+
+ + + {errors.message && touched.message && ( +
{errors.message}
+ )} +
+
+ ); + }} +
+ ); +}; + +export default CreateIssueModal; diff --git a/src/components/IssueModal/constants.ts b/src/components/IssueModal/constants.ts new file mode 100644 index 000000000..92cf6bc77 --- /dev/null +++ b/src/components/IssueModal/constants.ts @@ -0,0 +1,34 @@ +import { defineMessages, MessageDescriptor } from 'react-intl'; +import { IssueType } from '../../../server/constants/issue'; + +const messages = defineMessages({ + issueAudio: 'Audio', + issueVideo: 'Video', + issueSubtitles: 'Subtitle', + issueOther: 'Other', +}); + +interface IssueOption { + name: MessageDescriptor; + issueType: IssueType; + mediaType?: 'movie' | 'tv'; +} + +export const issueOptions: IssueOption[] = [ + { + name: messages.issueVideo, + issueType: IssueType.VIDEO, + }, + { + name: messages.issueAudio, + issueType: IssueType.AUDIO, + }, + { + name: messages.issueSubtitles, + issueType: IssueType.SUBTITLES, + }, + { + name: messages.issueOther, + issueType: IssueType.OTHER, + }, +]; diff --git a/src/components/IssueModal/index.tsx b/src/components/IssueModal/index.tsx new file mode 100644 index 000000000..f3f226de9 --- /dev/null +++ b/src/components/IssueModal/index.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import Transition from '../Transition'; +import CreateIssueModal from './CreateIssueModal'; + +interface IssueModalProps { + show?: boolean; + onCancel: () => void; + mediaType: 'movie' | 'tv'; + tmdbId: number; + issueId?: never; +} + +const IssueModal: React.FC = ({ + show, + mediaType, + onCancel, + tmdbId, +}) => ( + + + +); + +export default IssueModal; diff --git a/src/components/LanguageSelector/index.tsx b/src/components/LanguageSelector/index.tsx index 652687da3..74c84b152 100644 --- a/src/components/LanguageSelector/index.tsx +++ b/src/components/LanguageSelector/index.tsx @@ -1,9 +1,7 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { sortBy } from 'lodash'; -import dynamic from 'next/dynamic'; import React, { useMemo } from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import type { OptionsType, OptionTypeBase } from 'react-select'; +import Select, { CSSObjectWithLabel } from 'react-select'; import useSWR from 'swr'; import { Language } from '../../../server/lib/settings'; import globalMessages from '../../i18n/globalMessages'; @@ -13,8 +11,6 @@ const messages = defineMessages({ languageServerDefault: 'Default ({language})', }); -const Select = dynamic(() => import('react-select'), { ssr: false }); - type OptionType = { value: string; label: string; @@ -22,11 +18,11 @@ type OptionType = { }; const selectStyles = { - multiValueLabel: (base: any, state: { data: { isFixed?: boolean } }) => { - return state.data.isFixed ? { ...base, paddingRight: 6 } : base; + multiValueLabel: (base: CSSObjectWithLabel, props: { data: OptionType }) => { + return props.data?.isFixed ? { ...base, paddingRight: 6 } : base; }, - multiValueRemove: (base: any, state: { data: { isFixed?: boolean } }) => { - return state.data.isFixed ? { ...base, display: 'none' } : base; + multiValueRemove: (base: CSSObjectWithLabel, props: { data: OptionType }) => { + return props.data?.isFixed ? { ...base, display: 'none' } : base; }, }; @@ -95,7 +91,7 @@ const LanguageSelector: React.FC = ({ }); return ( - 0 ? '1.75rem' : '' }} - className="block w-full py-2 pl-10 text-white placeholder-gray-300 bg-gray-900 border border-gray-600 rounded-full bg-opacity-80 focus:bg-opacity-100 focus:border-gray-500 hover:border-gray-500 focus:outline-none focus:ring-0 focus:placeholder-gray-400 sm:text-base" + className="block w-full rounded-full border border-gray-600 bg-gray-900 bg-opacity-80 py-2 pl-10 text-white placeholder-gray-300 hover:border-gray-500 focus:border-gray-500 focus:bg-opacity-100 focus:placeholder-gray-400 focus:outline-none focus:ring-0 sm:text-base" placeholder={intl.formatMessage(messages.searchPlaceholder)} type="search" - inputMode="search" + autoComplete="off" value={searchValue} onChange={(e) => setSearchValue(e.target.value)} onFocus={() => setIsOpen(true)} @@ -36,13 +36,19 @@ const SearchInput: React.FC = () => { setIsOpen(false); } }} + onKeyUp={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + (e.target as HTMLInputElement).blur(); + } + }} /> {searchValue.length > 0 && ( )}
diff --git a/src/components/Layout/Sidebar/index.tsx b/src/components/Layout/Sidebar/index.tsx index 689faf909..45716eeb5 100644 --- a/src/components/Layout/Sidebar/index.tsx +++ b/src/components/Layout/Sidebar/index.tsx @@ -1,6 +1,7 @@ import { ClockIcon, CogIcon, + ExclamationIcon, SparklesIcon, UsersIcon, XIcon, @@ -17,6 +18,7 @@ import VersionStatus from '../VersionStatus'; const messages = defineMessages({ dashboard: 'Discover', requests: 'Requests', + issues: 'Issues', users: 'Users', settings: 'Settings', }); @@ -33,32 +35,47 @@ interface SidebarLinkProps { activeRegExp: RegExp; as?: string; requiredPermission?: Permission | Permission[]; + permissionType?: 'and' | 'or'; } const SidebarLinks: SidebarLinkProps[] = [ { href: '/', messagesKey: 'dashboard', - svgIcon: , + svgIcon: , activeRegExp: /^\/(discover\/?(movies|tv)?)?$/, }, { href: '/requests', messagesKey: 'requests', - svgIcon: , + svgIcon: , activeRegExp: /^\/requests/, }, + { + href: '/issues', + messagesKey: 'issues', + svgIcon: ( + + ), + activeRegExp: /^\/issues/, + requiredPermission: [ + Permission.MANAGE_ISSUES, + Permission.CREATE_ISSUES, + Permission.VIEW_ISSUES, + ], + permissionType: 'or', + }, { href: '/users', messagesKey: 'users', - svgIcon: , + svgIcon: , activeRegExp: /^\/users/, requiredPermission: Permission.MANAGE_USERS, }, { href: '/settings', messagesKey: 'settings', - svgIcon: , + svgIcon: , activeRegExp: /^\/settings/, requiredPermission: Permission.MANAGE_SETTINGS, }, @@ -97,31 +114,33 @@ const Sidebar: React.FC = ({ open, setClosed }) => { leaveTo="-translate-x-full" > <> -
-
+
+
-
+ -
-
+
{/* */}
@@ -175,20 +194,22 @@ const Sidebar: React.FC = ({ open, setClosed }) => {
-
-
-
-
+
+
+
+ -