Compare commits
119 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd90adba4d | ||
|
|
970aa612fe | ||
|
|
71169944dc | ||
|
|
5add44cfb0 | ||
|
|
a524b9c4c8 | ||
|
|
b5a3a7a89f | ||
|
|
67ac9e075f | ||
|
|
ce0687922a | ||
|
|
ba106c447d | ||
|
|
8cbf39a9d1 | ||
|
|
78b15cad18 | ||
|
|
d77f9eb44e | ||
|
|
8c0903393c | ||
|
|
22f2037ea6 | ||
|
|
3e613a3e93 | ||
|
|
5c847026aa | ||
|
|
2f017fe2cb | ||
|
|
7d95fd0f30 | ||
|
|
28b1d8cf4c | ||
|
|
9d6970db57 | ||
|
|
f906711ee8 | ||
|
|
93b57a76f1 | ||
|
|
12db7a065a | ||
|
|
058fb65495 | ||
|
|
721ed9a930 | ||
|
|
5295f74915 | ||
|
|
7d6c6cb2a1 | ||
|
|
d53d61014f | ||
|
|
7c40d9131d | ||
|
|
f124d732a2 | ||
|
|
8af6a1f566 | ||
|
|
d00e470b55 | ||
|
|
55f9e41f1b | ||
|
|
343f466788 | ||
|
|
fae4818957 | ||
|
|
d188f6ffad | ||
|
|
289864af1a | ||
|
|
d2620281bf | ||
|
|
30ed82e155 | ||
|
|
c034496f55 | ||
|
|
2dcda39d40 | ||
|
|
75a426437a | ||
|
|
1438b08cf0 | ||
|
|
0185bb1a70 | ||
|
|
c8d4d674f4 | ||
|
|
7434a26f76 | ||
|
|
46af705014 | ||
|
|
2b27a715b0 | ||
|
|
2cdf755c20 | ||
|
|
f99ab47c01 | ||
|
|
c8c74b0ae5 | ||
|
|
de3d288949 | ||
|
|
6353cda582 | ||
|
|
1163e81adc | ||
|
|
5d06a34731 | ||
|
|
8f292d538b | ||
|
|
4809257816 | ||
|
|
02969d5426 | ||
|
|
ed94a0f335 | ||
|
|
395cbb2be6 | ||
|
|
bd947409e6 | ||
|
|
06fc98b6b9 | ||
|
|
d7b1c28406 | ||
|
|
3438045e51 | ||
|
|
4b7af86111 | ||
|
|
dd22d158ff | ||
|
|
e88dc83aeb | ||
|
|
329a814a8f | ||
|
|
a333a09582 | ||
|
|
f2ebba7b1d | ||
|
|
17518dbe7f | ||
|
|
2350f3ffd8 | ||
|
|
3dced2065f | ||
|
|
7e4175ff21 | ||
|
|
4157432aa7 | ||
|
|
941fe19904 | ||
|
|
2e0e4d5129 | ||
|
|
9f5f920c23 | ||
|
|
1fb7ea7258 | ||
|
|
3bbc716434 | ||
|
|
6898357b13 | ||
|
|
a7db01fba4 | ||
|
|
0c8a180189 | ||
|
|
0980fa54f9 | ||
|
|
32b4c99950 | ||
|
|
22002ab4c7 | ||
|
|
659fa505f0 | ||
|
|
4205e32ae7 | ||
|
|
ffe9e19c3b | ||
|
|
66ef72dd42 | ||
|
|
1056d28a26 | ||
|
|
741b2c4e9f | ||
|
|
6dbe9ea814 | ||
|
|
69fd7a5511 | ||
|
|
96328ab5c6 | ||
|
|
c31709681a | ||
|
|
4b505223b8 | ||
|
|
16e48c4919 | ||
|
|
604ba2a92f | ||
|
|
12127a7763 | ||
|
|
9a4da25faa | ||
|
|
7e8f361af7 | ||
|
|
08c22b03e0 | ||
|
|
3cee6b78a4 | ||
|
|
7083ddf181 | ||
|
|
b092361c73 | ||
|
|
6f2a71c788 | ||
|
|
22006e9dbd | ||
|
|
e7ee85c29b | ||
|
|
f35dae56a5 | ||
|
|
5deb64a87f | ||
|
|
6753d9daaa | ||
|
|
4891298891 | ||
|
|
ab9cef3624 | ||
|
|
d4d79e438c | ||
|
|
c6836e02c8 | ||
|
|
0916b58594 | ||
|
|
44a305426f | ||
|
|
764db94f1b |
@@ -189,6 +189,62 @@
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "danshilm",
|
||||
"name": "Danshil Mungur",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/20923978?v=4",
|
||||
"profile": "https://github.com/danshilm",
|
||||
"contributions": [
|
||||
"code",
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "doob187",
|
||||
"name": "doob187",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/60312740?v=4",
|
||||
"profile": "https://github.com/doob187",
|
||||
"contributions": [
|
||||
"infra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "johnpyp",
|
||||
"name": "johnpyp",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/20625636?v=4",
|
||||
"profile": "https://github.com/johnpyp",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "ankarhem",
|
||||
"name": "Jakob Ankarhem",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/14110063?v=4",
|
||||
"profile": "https://github.com/ankarhem",
|
||||
"contributions": [
|
||||
"doc",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "jayesh100",
|
||||
"name": "Jayesh",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/8022175?v=4",
|
||||
"profile": "https://github.com/jayesh100",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "flying-sausages",
|
||||
"name": "flying-sausages",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/23618693?v=4",
|
||||
"profile": "https://github.com/flying-sausages",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
}
|
||||
],
|
||||
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
||||
|
||||
@@ -4,7 +4,12 @@ node_modules
|
||||
.gitconfig
|
||||
.gitignore
|
||||
.github
|
||||
.all-contributorsrc
|
||||
.editorconfig
|
||||
.prettierignore
|
||||
**/README.md
|
||||
**/.vscode
|
||||
config/db/db.sqlite3
|
||||
config/db/logs/overseerr.log
|
||||
Dockerfil**
|
||||
**.md
|
||||
|
||||
5
.gitbook.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
root: ./docs
|
||||
|
||||
structure:
|
||||
readme: README.md
|
||||
summary: SUMMARY.md
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -2,7 +2,7 @@
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
labels: 'awaiting-triage, type:bug'
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -2,7 +2,7 @@
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: enhancement
|
||||
labels: 'awaiting-triage, type:enhancement'
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
|
||||
3
.gitignore
vendored
@@ -36,7 +36,8 @@ config/db/db.sqlite3
|
||||
config/settings.json
|
||||
|
||||
# logs
|
||||
config/logs/*.log
|
||||
config/logs/*.log*
|
||||
config/logs/*.json
|
||||
|
||||
# dist files
|
||||
dist
|
||||
|
||||
136
CHANGELOG.md
@@ -1,3 +1,139 @@
|
||||
# [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)
|
||||
|
||||
|
||||
|
||||
@@ -14,16 +14,46 @@ All help is welcome and greatly appreciated. If you would like to contribute to
|
||||
### Getting Started
|
||||
|
||||
1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
||||
2. Create a new branch `git checkout -b BRANCH_NAME`
|
||||
```
|
||||
git clone https://github.com/YOUR_USERNAME/overseerr.git
|
||||
cd overseerr/
|
||||
```
|
||||
2. Add the remote upstream.
|
||||
|
||||
- Its recommended to name the branch something relevant to the feature or fix you are working on.
|
||||
- An example of this would be `fix-title-cards` or `feature-new-system`.
|
||||
- Bad examples would be `patch` or `bug`.
|
||||
```
|
||||
git remote add upstream https://github.com/sct/overseerr.git
|
||||
```
|
||||
|
||||
3. Install dependencies `yarn`
|
||||
4. `yarn dev` to build and watch for changes
|
||||
3. Create a new branch
|
||||
|
||||
You can also run the development environment in [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
|
||||
```
|
||||
git checkout -b BRANCH_NAME develop
|
||||
```
|
||||
|
||||
- Its recommended to name the branch something relevant to the feature or fix you are working on.
|
||||
- An example of this would be `fix-title-cards` or `feature-new-system`.
|
||||
- Bad examples would be `patch` or `bug`.
|
||||
|
||||
4. Run development environment
|
||||
|
||||
```
|
||||
yarn
|
||||
yarn dev
|
||||
```
|
||||
|
||||
- Alternatively you can run using [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
|
||||
|
||||
5. Create your patch and run appropriate tests.
|
||||
|
||||
6. Follow the [guidelines](#contributing-code).
|
||||
|
||||
7. Should you need to update your fork you can do so by rebasing from upstream.
|
||||
|
||||
```
|
||||
git fetch upstream
|
||||
git rebase upstream/develop
|
||||
git push origin BRANCH_NAME -f
|
||||
```
|
||||
|
||||
### Contributing Code
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
FROM node:12.18-alpine AS BUILD_IMAGE
|
||||
|
||||
ARG COMMIT_TAG
|
||||
ENV COMMIT_TAG=${COMMIT_TAG}
|
||||
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
@@ -25,6 +28,8 @@ COPY --from=BUILD_IMAGE /app/dist ./dist
|
||||
COPY --from=BUILD_IMAGE /app/.next ./.next
|
||||
COPY --from=BUILD_IMAGE /app/node_modules ./node_modules
|
||||
|
||||
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
||||
|
||||
CMD yarn start
|
||||
|
||||
EXPOSE 5055
|
||||
|
||||
20
README.md
@@ -16,7 +16,7 @@
|
||||
<a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a>
|
||||
<img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr">
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-20-orange.svg"/></a>
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-26-orange.svg"/></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
</p>
|
||||
|
||||
@@ -36,16 +36,21 @@
|
||||
|
||||
- User profiles.
|
||||
- User settings page (to give users the ability to modify their Overseerr experience to their liking).
|
||||
- Version update notifications in-app.
|
||||
- 4K requests (Includes multi-radarr/sonarr management for media)
|
||||
|
||||
## Planned Features
|
||||
|
||||
- More notification types (Slack/Telegram/etc.).
|
||||
- More notification types.
|
||||
- Issues system. This will allow users to report issues with content on your media server.
|
||||
- Local user system (for those who don't use Plex).
|
||||
- Compatibility APIs (to work with existing tools in your system).
|
||||
|
||||
## Getting Started
|
||||
|
||||
Check out our documenation for steps on how to install and run Overseerr:
|
||||
|
||||
https://docs.overseerr.dev/getting-started/installation
|
||||
|
||||
## Running Overseerr
|
||||
|
||||
Currently, Overseerr is only distributed through Docker images. If you have Docker, you can run Overseerr as per:
|
||||
@@ -70,6 +75,7 @@ After running Overseerr for the first time, configure it by visiting the web UI
|
||||
|
||||
## Support
|
||||
|
||||
- Check out the [Overseerr Documentation](https://docs.overseerr.dev/) before asking for help. Your question might already be in the [FAQ](https://docs.overseerr.dev/support/faq).
|
||||
- You can get support on [Discord](https://discord.gg/PkCWJSeCk7).
|
||||
- You can ask questions in the Help category of our [GitHub Discussions](https://github.com/sct/overseerr/discussions).
|
||||
- Bugs/Feature Requests can be opened via a [GitHub issue](https://github.com/sct/overseerr/issues).
|
||||
@@ -123,6 +129,14 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
<td align="center"><a href="https://github.com/Panzer1119"><img src="https://avatars1.githubusercontent.com/u/23016343?v=4" width="100px;" alt=""/><br /><sub><b>Paul Hagedorn</b></sub></a><br /><a href="#translation-Panzer1119" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/Shagon94"><img src="https://avatars3.githubusercontent.com/u/9140783?v=4" width="100px;" alt=""/><br /><sub><b>Shagon94</b></sub></a><br /><a href="#translation-Shagon94" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/sebstrgg"><img src="https://avatars3.githubusercontent.com/u/27026694?v=4" width="100px;" alt=""/><br /><sub><b>sebstrgg</b></sub></a><br /><a href="#translation-sebstrgg" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/danshilm"><img src="https://avatars2.githubusercontent.com/u/20923978?v=4" width="100px;" alt=""/><br /><sub><b>Danshil Mungur</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/doob187"><img src="https://avatars1.githubusercontent.com/u/60312740?v=4" width="100px;" alt=""/><br /><sub><b>doob187</b></sub></a><br /><a href="#infra-doob187" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center"><a href="https://github.com/johnpyp"><img src="https://avatars2.githubusercontent.com/u/20625636?v=4" width="100px;" alt=""/><br /><sub><b>johnpyp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=johnpyp" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/ankarhem"><img src="https://avatars1.githubusercontent.com/u/14110063?v=4" width="100px;" alt=""/><br /><sub><b>Jakob Ankarhem</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Documentation">📖</a> <a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/jayesh100"><img src="https://avatars1.githubusercontent.com/u/8022175?v=4" width="100px;" alt=""/><br /><sub><b>Jayesh</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jayesh100" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/flying-sausages"><img src="https://avatars1.githubusercontent.com/u/23618693?v=4" width="100px;" alt=""/><br /><sub><b>flying-sausages</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=flying-sausages" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
22
docs/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Introduction
|
||||
|
||||
Welcome to the Overseerr Documentation.
|
||||
|
||||
## Features
|
||||
|
||||
- **Full Plex integration**. Login and manage user access with Plex.
|
||||
- **Syncs to your Plex library** to show what titles you already have.
|
||||
- **Integrates with Sonarr and Radarr**. With more services to come in the future.
|
||||
- **Easy to use request system** allowing users to request individual seasons or movies in a friendly, clean UI.
|
||||
- **Simple request management UI**. Don't dig through the app to approve recent requests.
|
||||
- **Mobile-friendly design**, for when you need to approve requests on the go.
|
||||
- Granular permission system.
|
||||
- Localization into other languages.
|
||||
|
||||
## Motivation
|
||||
|
||||
The primary motivation for starting this project was to have an incredibly performant and easy to use application. There is a heavy focus on the user experience for both the server owner and the users. We feel requesting should be **effortless for the user**. Find the media you want, click request, and branch off efficiently into other titles that interest you, all in one seamless flow. For the server owner, Overseerr takes all the hassle out of approving your users' requests.
|
||||
|
||||
## We need your help!
|
||||
|
||||
Overseerr is an ambitious project. We have already poured a lot of work into this, with more coming. We need your valuable feedback and help with finding bugs. Also, being that this is an open-source project, anyone is welcome to contribute. Contribution includes building features, patching bugs, or even translating the application. You can find the contribution guide on our GitHub.
|
||||
17
docs/SUMMARY.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Table of contents
|
||||
|
||||
* [Introduction](README.md)
|
||||
|
||||
## Getting Started
|
||||
|
||||
* [Installation](getting-started/installation.md)
|
||||
|
||||
## Support
|
||||
|
||||
* [Frequently Asked Questions](support/faq.md)
|
||||
* [Asking for Support](support/asking-for-support.md)
|
||||
|
||||
## Extending Overseerr
|
||||
|
||||
* [Reverse Proxy Examples](extending-overseerr/reverse-proxy-examples.md)
|
||||
|
||||
132
docs/extending-overseerr/reverse-proxy-examples.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Reverse Proxy Examples
|
||||
|
||||
{% hint style="warning" %}
|
||||
Base URLs cannot be configured in Overseerr. With this limitation, only subdomain configurations are supported.
|
||||
{% endhint %}
|
||||
|
||||
## LE/SWAG
|
||||
|
||||
### Subdomain
|
||||
|
||||
Place in the `proxy-confs` folder as `overseerr.subdomain.conf`
|
||||
|
||||
Example Configuration:
|
||||
|
||||
```text
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
|
||||
server_name overseerr.*;
|
||||
|
||||
include /config/nginx/ssl.conf;
|
||||
|
||||
client_max_body_size 0;
|
||||
|
||||
location / {
|
||||
|
||||
include /config/nginx/proxy.conf;
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $upstream_app overseerr;
|
||||
set $upstream_port 5055;
|
||||
set $upstream_proto http;
|
||||
proxy_pass $upstream_proto://$upstream_app:$upstream_port;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
## Traefik \(v2\)
|
||||
|
||||
Add the labels to the Overseerr service in your `docker-compose` file. A basic example for a `docker-compose` file using Traefik can be found [here](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/).
|
||||
|
||||
### Subdomain
|
||||
|
||||
Example Configuration:
|
||||
|
||||
```text
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
## HTTP Routers
|
||||
- "traefik.http.routers.overseerr-rtr.entrypoints=https"
|
||||
- "traefik.http.routers.overseerr-rtr.rule=Host(`overseerr.domain.com`)"
|
||||
- "traefik.http.routers.overseerr-rtr.tls=true"
|
||||
## HTTP Services
|
||||
- "traefik.http.routers.overseerr-rtr.service=overseerr-svc"
|
||||
- "traefik.http.services.overseerr-svc.loadbalancer.server.port=5055"
|
||||
```
|
||||
|
||||
## LE/NGINX
|
||||
|
||||
### Subdomain
|
||||
|
||||
Take the configuration below and place it in `/etc/nginx/sites-available/overseerr.example.com.conf`.
|
||||
|
||||
Create a symlink to `/etc/nginx/sites-enabled`:
|
||||
|
||||
```text
|
||||
sudo ln -s /etc/nginx/sites-available/overseerr.example.com.conf /etc/nginx/sites-enabled/overseerr.example.com.conf
|
||||
```
|
||||
|
||||
Test the configuration:
|
||||
|
||||
```text
|
||||
sudo nginx -t
|
||||
```
|
||||
|
||||
Reload your configuration for NGINX:
|
||||
|
||||
```text
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
Example Configuration:
|
||||
|
||||
```text
|
||||
server {
|
||||
listen 80;
|
||||
server_name overseerr.example.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name overseerr.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/overseerr.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/overseerr.example.com/privkey.pem;
|
||||
|
||||
proxy_set_header Referer $http_referer;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Real-Port $remote_port;
|
||||
proxy_set_header X-Forwarded-Host $host:$remote_port;
|
||||
proxy_set_header X-Forwarded-Server $host;
|
||||
proxy_set_header X-Forwarded-Port $remote_port;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Ssl on;
|
||||
real_ip_header CF-Connecting-IP;
|
||||
# Control the behavior of the Referer header (Referrer-Policy)
|
||||
add_header Referrer-Policy "no-referrer";
|
||||
# HTTP Strict Transport Security
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||
# Reduce XSS risks (Content-Security-Policy)
|
||||
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://plex.tv; style-src 'self' 'unsafe-inline' https://rsms.me/inter/inter.css; script-src 'self'; img-src 'self' data: https://plex.tv https://assets.plex.tv https://secure.gravatar.com https://i2.wp.com https://image.tmdb.org; font-src 'self' https://rsms.me/inter/font-files/" always;
|
||||
# Prevent some categories of XSS attacks (X-XSS-Protection)
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
# Provide clickjacking protection (X-Frame-Options)
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
# Prevent Sniff Mimetype (X-Content-Type-Options)
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
|
||||
access_log /var/log/nginx/overseerr.example.com-access.log;
|
||||
error_log /var/log/nginx/overseerr.example.com-error.log;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5055;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
196
docs/getting-started/installation.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Installation
|
||||
|
||||
{% hint style="danger" %}
|
||||
Overseerr is currently under very heavy, rapid development and things are likely to break often. We need all the help we can get to find bugs and get them fixed to hit a more stable release. If you would like to help test the bleeding edge, please use the image **`sctx/overseerr:develop`** instead!
|
||||
{% endhint %}
|
||||
|
||||
{% hint style="info" %}
|
||||
After running Overseerr for the first time, configure it by visiting the web UI at `http://[address]:5055` and completing the setup steps.
|
||||
{% endhint %}
|
||||
|
||||
## Docker
|
||||
|
||||
{% tabs %}
|
||||
{% tab title="Basic" %}
|
||||
```bash
|
||||
docker run -d \
|
||||
-e LOG_LEVEL=info \
|
||||
-e TZ=Asia/Tokyo \
|
||||
-p 5055:5055 \
|
||||
-v /path/to/appdata/config:/app/config \
|
||||
--restart unless-stopped \
|
||||
sctx/overseerr
|
||||
```
|
||||
{% endtab %}
|
||||
|
||||
{% tab title="UID/GID" %}
|
||||
```text
|
||||
docker run -d \
|
||||
--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ] \
|
||||
-e LOG_LEVEL=info \
|
||||
-e TZ=Asia/Tokyo \
|
||||
-p 5055:5055 \
|
||||
-v /path/to/appdata/config:/app/config \
|
||||
--restart unless-stopped \
|
||||
sctx/overseerr
|
||||
```
|
||||
{% endtab %}
|
||||
|
||||
{% tab title="Manual Update" %}
|
||||
```text
|
||||
# Stop the Overseerr container
|
||||
docker stop overseerr
|
||||
|
||||
# Remove the Overseerr container
|
||||
docker rm overseerr
|
||||
|
||||
# Pull the latest update
|
||||
docker pull sctx/overseerr
|
||||
|
||||
# Run the Overseerr container with the same parameters as before
|
||||
docker run -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.
|
||||
2. Inside the **Communtiy Applications** app store, search for **Overseerr**.
|
||||
3. Click the **Install Button**.
|
||||
4. On the following **Add Container** screen, make changes to the **Host Port** and **Host Path 1**\(Appdata\) as needed.
|
||||
5. Click apply and access "Overseerr" at your `<ServerIP:HostPort>` in a web browser.
|
||||
|
||||
## Windows
|
||||
|
||||
Please refer to the [docker for windows documentation](https://docs.docker.com/docker-for-windows/) for installation.
|
||||
|
||||
{% hint style="danger" %}
|
||||
**WSL2 will need to be installed to prevent DB corruption! Please see** [**Docker Desktop WSL 2 backend**](https://docs.docker.com/docker-for-windows/wsl/) **on how to enable WSL2. The command below will only work with WSL2 installed! Details below.**
|
||||
{% endhint %}
|
||||
|
||||
```bash
|
||||
docker run -d -e LOG_LEVEL=info -e TZ=Asia/Tokyo -p 5055:5055 -v "/your/path/here:/app/config" --restart unless-stopped sctx/overseerr
|
||||
```
|
||||
|
||||
{% hint style="info" %}
|
||||
Docker on Windows works differently than it does on Linux; it uses a VM to run a stripped-down Linux and then runs docker within that. The volume mounts are exposed to the docker in this VM via SMB mounts. While this is fine for media, it is unacceptable for the `/app/config` directory because SMB does not support file locking. This will eventually corrupt your database which can lead to slow behavior and crashes. If you must run in docker on Windows, you should put the `/app/config` directory mount inside the VM and not on the Windows host. It's worth noting that this warning also extends to other containers which use SQLite databases.
|
||||
{% endhint %}
|
||||
|
||||
## Linux \(Unsupported\)
|
||||
{% tabs %}
|
||||
|
||||
{% tab title="Ubuntu 16.04+/Debian" %}
|
||||
{% hint style="danger" %}
|
||||
This install method is **not currently supported**. Docker is the only install method supported. Do not create issues or ask for support unless you are able to reproduce the issue with Docker.
|
||||
{% endhint %}
|
||||
|
||||
```bash
|
||||
# Install nodejs
|
||||
sudo apt-get install -y curl git gnupg2
|
||||
curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
# Install yarn
|
||||
curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
|
||||
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
|
||||
sudo apt-get update && sudo apt-get install yarn
|
||||
# Install Overseerr
|
||||
cd ~ && git clone https://github.com/sct/overseerr.git
|
||||
cd overseerr
|
||||
yarn install
|
||||
yarn build
|
||||
yarn start
|
||||
```
|
||||
|
||||
**Updating**
|
||||
|
||||
In order to update, you will need to re-build overseer.
|
||||
```bash
|
||||
cd ~/.overseerr
|
||||
git pull
|
||||
yarn install
|
||||
yarn build
|
||||
yarn start
|
||||
```
|
||||
{% endtab %}
|
||||
|
||||
{% tab title="Ubuntu ARM" %}
|
||||
{% hint style="danger" %}
|
||||
This install method is **not currently supported**. Docker is the only install method supported. Do not create issues or ask for support unless you are able to reproduce the issue with Docker.
|
||||
{% endhint %}
|
||||
|
||||
```bash
|
||||
# Install nodejs
|
||||
sudo apt-get install -y curl git gnupg2 build-essential
|
||||
curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
# Install yarn
|
||||
curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
|
||||
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
|
||||
sudo apt-get update && sudo apt-get install yarn
|
||||
# Install Overseerr
|
||||
cd ~ && git clone https://github.com/sct/overseerr.git
|
||||
cd overseerr
|
||||
npm config set python "$(which python3)"
|
||||
yarn install
|
||||
yarn build
|
||||
yarn start
|
||||
```
|
||||
|
||||
**Updating**
|
||||
|
||||
In order to update, you will need to re-build overseer.
|
||||
```bash
|
||||
cd ~/.overseerr
|
||||
git pull
|
||||
yarn install
|
||||
yarn build
|
||||
yarn start
|
||||
```
|
||||
{% endtab %}
|
||||
|
||||
{% tab title="ArchLinux \(3rd Party\)" %}
|
||||
Built from tag \(master\): [https://aur.archlinux.org/packages/overseerr/](https://aur.archlinux.org/packages/overseerr/)
|
||||
Built from latest \(develop\): [aur.archlinux.org/packages/overseerr-git](https://aur.archlinux.org/packages/overseerr-git/)
|
||||
**To install these just use your favorite AUR package manager:**
|
||||
|
||||
```bash
|
||||
yay -S overseer
|
||||
```
|
||||
{% endtab %}
|
||||
|
||||
{% tab title="Gentoo \(3rd Party\)" %}
|
||||
Portage overlay [GitHub Repository](https://github.com/chriscpritchard/overseerr-overlay)
|
||||
|
||||
Efforts will be made to keep up to date with the latest releases, however, this cannot be guaranteed.
|
||||
|
||||
To enable using eselect repository, run:
|
||||
```bash
|
||||
eselect repository add overseerr-overlay git https://github.com/chriscpritchard/overseerr-overlay.git
|
||||
```
|
||||
|
||||
Once complete, you can just run:
|
||||
```bash
|
||||
emerge www-apps/overseerr
|
||||
```
|
||||
{% endtab %}
|
||||
|
||||
{% endtabs %}
|
||||
|
||||
## Swizzin \(Third party\)
|
||||
The installation is not implemented via docker, but barebones. The latest released version of overseerr will be used.
|
||||
Please see the [swizzin documentation](https://swizzin.ltd/applications/overseerr) for more information.
|
||||
|
||||
To install, run the following:
|
||||
```bash
|
||||
box install overseerr
|
||||
```
|
||||
|
||||
To upgrade, run the following:
|
||||
```bash
|
||||
box upgrade overseerr
|
||||
```
|
||||
34
docs/support/asking-for-support.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Asking for Support
|
||||
|
||||
## Before asking for support, make sure you try these things first
|
||||
|
||||
* Make sure you have **updated** to the latest version.
|
||||
* ["Have you tried turning it off and on again?"](https://www.youtube.com/watch?v=nn2FB1P_Mn8)
|
||||
* **Analyzing** your logs, you just might find the solution yourself!
|
||||
* **Search** the [Wiki](../), [Installation Guides](../getting-started/installation.md), and [FAQs](faq.md).
|
||||
* If you have questions, feel free to ask them on [Discord](https://discord.gg/PkCWJSeCk7) \(Please review our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md)\). Please include a link to your logs. See [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) for more details.
|
||||
|
||||
## What should I include when asking for support?
|
||||
|
||||
When you contact support saying something like "it doesn't work" leaves little to go on to figure out what is wrong for you. When contacting support try to include information such as the following:
|
||||
|
||||
* What did you try to do? When you describe what you did to reach the state you are in we may notice something you did different from the instructions, or something that your unique setup requires in addition. Some examples of what to provide here:
|
||||
* What command did you enter?
|
||||
* What did you click on?
|
||||
* What settings did you change?
|
||||
* Provide a step-by-step list of what you tried.
|
||||
* What do you see? We cannot see your screen so some of the following is necessary for us to know what is going on:
|
||||
* Did something happen?
|
||||
* Did something not happen?
|
||||
* Are there any error messages showing?
|
||||
* Screenshots can help us see what you are seeing
|
||||
* The Overseerr logs show exactly what happened and are often critical for identifying issues \(see [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below\).
|
||||
|
||||
## How can I share my logs?
|
||||
|
||||
First you will need to gather your logs from the install directory.
|
||||
|
||||
1. Collect the log file from `<Overseeerr-install-directory>/logs/overseerr.log`
|
||||
2. Open the log file and **upload the text** by going to [gist.github.com](https://gist.github.com/) and creating a new secret Gist of the contents.
|
||||
3. **Share the link** with support in [Discord](https://discord.gg/PkCWJSeCk7) by copying the URL of the page.
|
||||
|
||||
95
docs/support/faq.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Frequently Asked Questions
|
||||
|
||||
{% hint style="info" %}
|
||||
If you can't find a solution here, please ask on [Discord](https://discord.gg/PkCWJSeCk7). Please do not post questions on the GitHub issues tracker.
|
||||
{% endhint %}
|
||||
|
||||
## General
|
||||
|
||||
### I receive 409 or 400 errors when requesting a movie or tv show!
|
||||
|
||||
**A:** Verify your are running radarr and sonarr v3. Overseerr was developed for v3 and is not currently backward compatible.
|
||||
|
||||
### How do I keep Overseerr up-to-date?
|
||||
|
||||
**A:** 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.
|
||||
|
||||
### How can I access Overseerr outside my home network?
|
||||
|
||||
**A:** The easy and least secure method is to forward an external port \(`5055`\) on your router to the internal port used by Overseerr \(default is TCP `5055`\). Visit [Port Forward](http://portforward.com/) for instructions for your particular router. You will then be able to access Overseerr via `http://EXTERNAL-IP-ADDRESS:5055`.
|
||||
|
||||
The more advanced and most preferred method \(and more secure if you use SSL\) is to set up a web server with NGINX/Apache, and use a reverse proxy to access Overseerr. You can lookup many guides on the internet to find out how to do this. There are several reverse proxy config examples located [here](../extending-overseerr/reverse-proxy-examples.md).
|
||||
|
||||
The most secure method, but also the most inconvenient, is to set up a VPN tunnel to your home server, then you can access Overseerr as if it is on a local network via `http://LOCAL-IP-ADDRESS:5055`.
|
||||
|
||||
### Overseerr is amazing! But it is not translated in my language yet! Can I help with translations?
|
||||
|
||||
**A:** You sure can! We are using Weblate for translations! Check it out [here](https://hosted.weblate.org/engage/overseerr/). If your language is not listed please open an [enhancement request in issues](https://github.com/sct/overseerr/issues/new/choose).
|
||||
|
||||
### Where can I find the changelog?
|
||||
|
||||
**A:** You can find the changelog in the **Settings -> About** page in your instance. You can also find it on github [here](https://github.com/sct/overseerr/releases).
|
||||
|
||||
### Can I make 4K requests?
|
||||
|
||||
**A:** 4K requests are not supported just yet but they will be supported in the future!
|
||||
|
||||
### Some media is missing from Overseerr that I know is in Plex!
|
||||
|
||||
**A:** Overseerr supports the new Plex Movie, Legacy Plex Movie, TheTVDB agent, and the TMDb agent. Please verify that your library is using one of the agents previously listed. If you are changing agents, a full metadata refresh will need to be performed. Caution, this can take a long time depending on how many items you have in your movie library.
|
||||
|
||||
**Troubleshooting Steps:**
|
||||
|
||||
Check the Overseerr logs for media items that are missing. The logs will contain an error as to why that item could not be matched. One example might be `errorMessage":"SQLITE_CONSTRAINT: NOT NULL`. This means that the TMDb ID is missing from the Plex XML for that item.
|
||||
|
||||
1. Verify that you are using one of the agents mentioned above.
|
||||
2. Refresh the metadata for just that item.
|
||||
3. Run a full scan in Overseerr to see if that item is now matched properly.
|
||||
4. If the item is now seen by Overseerr then repeat step 2 for each missing item. If you have a large amount of items missing then a full metadata refresh is recommended for that library.
|
||||
5. Run a full scan on Overseerr after refreshing all unmatched items.
|
||||
|
||||
Perform these steps to verify the media item has a guid Overseerr can match.
|
||||
|
||||
1. Go to the media item in Plex and **"Get info"** and click on **"View XML"**.
|
||||
2. Verify that the media item has the same format of one of the examples below.
|
||||
|
||||
**Examples:**
|
||||
|
||||
1. TMDB agent `guid="com.plexapp.agents.themoviedb://1705"`
|
||||
2. The new Plex Movie agent `<Guid id="tmdb://464052"/>`
|
||||
3. TheTVDB agent `guid="com.plexapp.agents.thetvdb://78874/1/1"`
|
||||
4. Legacy Plex Movie agent `guid="com.plexapp.agents.imdb://tt0765446"`
|
||||
|
||||
### Where can I find the logs?
|
||||
|
||||
**A:** The logs are located at `<Overseeerr-install-directory>/logs/overseerr.log`
|
||||
|
||||
## User Management
|
||||
|
||||
### Why can't I see all my Plex users?
|
||||
|
||||
**A:** Navigate to your **User List** in Overseerr and click **Import Users From Plex** button. Don't forget to check the default user permissions in the **Settings -> General Settings** page beforehand.
|
||||
|
||||
### Can I create local users in Overseerr?
|
||||
|
||||
**A:** Not at this time. But it is a planned feature!
|
||||
|
||||
### Is is possible to set user roles in Overseerr?
|
||||
|
||||
**A:** Unfortunately, this is not possible yet. It is planned!
|
||||
|
||||
## Requests
|
||||
|
||||
### I approved a requested movie and radarr didn't search for it!
|
||||
|
||||
**A:** Check your minimum availability in radarr. If an added item does not meet the minimum availability, no search will be performed. Also verify that radarr did not search for it by checking the radarr logs. Lastly, verify the item was not already being monitored by radarr. Currently there is no state sync with radarr.
|
||||
|
||||
### Help! My request still shows "requested" even though it's in Plex!?!
|
||||
|
||||
**A:** See "[Some media is missing from Overseerr that I know is in Plex!](./faq.md#some-media-is-missing-from-overseerr-that-i-know-is-in-plex)" for troubleshooting steps.
|
||||
|
||||
## Notifications
|
||||
|
||||
### I am getting "Username and Password not accepted" when sending email notifications to gmail!
|
||||
|
||||
**A:** If you have 2-Step Verification enabled on your account you will need to create an app password. More details can be found [here](https://support.google.com/mail/answer/185833).
|
||||
@@ -1,4 +1,7 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
commitTag: process.env.COMMIT_TAG || 'local',
|
||||
},
|
||||
webpack(config) {
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
|
||||
@@ -2,6 +2,7 @@ const devConfig = {
|
||||
type: 'sqlite',
|
||||
database: 'config/db/db.sqlite3',
|
||||
synchronize: true,
|
||||
migrationsRun: false,
|
||||
logging: false,
|
||||
entities: ['server/entity/**/*.ts'],
|
||||
migrations: ['server/migration/**/*.ts'],
|
||||
@@ -19,7 +20,7 @@ const prodConfig = {
|
||||
logging: false,
|
||||
entities: ['dist/entity/**/*.js'],
|
||||
migrations: ['dist/migration/**/*.js'],
|
||||
migrationsRun: true,
|
||||
migrationsRun: false,
|
||||
subscribers: ['dist/subscriber/**/*.js'],
|
||||
cli: {
|
||||
entitiesDir: 'dist/entity',
|
||||
|
||||
@@ -58,6 +58,9 @@ components:
|
||||
applicationUrl:
|
||||
type: string
|
||||
example: https://os.example.com
|
||||
defaultPermissions:
|
||||
type: number
|
||||
example: 32
|
||||
PlexLibrary:
|
||||
type: object
|
||||
properties:
|
||||
@@ -380,6 +383,36 @@ components:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
RelatedVideo:
|
||||
type: object
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
example: https://www.youtube.com/watch?v=9qhL2_UxXM0/
|
||||
key:
|
||||
type: string
|
||||
example: 9qhL2_UxXM0
|
||||
name:
|
||||
type: string
|
||||
example: Trailer for some movie (1978)
|
||||
size:
|
||||
type: number
|
||||
example: 1080
|
||||
type:
|
||||
type: string
|
||||
example: Trailer
|
||||
enum:
|
||||
- Clip
|
||||
- Teaser
|
||||
- Trailer
|
||||
- Featurette
|
||||
- Opening Credits
|
||||
- Behind the Scenes
|
||||
- Bloopers
|
||||
site:
|
||||
type: string
|
||||
enum:
|
||||
- 'YouTube'
|
||||
MovieDetails:
|
||||
type: object
|
||||
properties:
|
||||
@@ -405,6 +438,10 @@ components:
|
||||
$ref: '#/components/schemas/Genre'
|
||||
homepage:
|
||||
type: string
|
||||
relatedVideos:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RelatedVideo'
|
||||
originalLanguage:
|
||||
type: string
|
||||
originalTitle:
|
||||
@@ -460,6 +497,19 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Crew'
|
||||
collection:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
example: 1
|
||||
name:
|
||||
type: string
|
||||
example: A collection
|
||||
posterPath:
|
||||
type: string
|
||||
backdropPath:
|
||||
type: string
|
||||
externalIds:
|
||||
$ref: '#/components/schemas/ExternalIds'
|
||||
mediaInfo:
|
||||
@@ -791,6 +841,36 @@ components:
|
||||
properties:
|
||||
webhookUrl:
|
||||
type: string
|
||||
SlackSettings:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
example: false
|
||||
types:
|
||||
type: number
|
||||
example: 2
|
||||
options:
|
||||
type: object
|
||||
properties:
|
||||
webhookUrl:
|
||||
type: string
|
||||
TelegramSettings:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
example: false
|
||||
types:
|
||||
type: number
|
||||
example: 2
|
||||
options:
|
||||
type: object
|
||||
properties:
|
||||
botAPI:
|
||||
type: string
|
||||
chatId:
|
||||
type: string
|
||||
NotificationEmailSettings:
|
||||
type: object
|
||||
properties:
|
||||
@@ -806,6 +886,9 @@ components:
|
||||
emailFrom:
|
||||
type: string
|
||||
example: no-reply@example.com
|
||||
senderName:
|
||||
type: string
|
||||
example: Overseerr
|
||||
smtpHost:
|
||||
type: string
|
||||
example: 127.0.0.1
|
||||
@@ -821,7 +904,9 @@ components:
|
||||
authPass:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
allowSelfSigned:
|
||||
type: boolean
|
||||
example: false
|
||||
PersonDetail:
|
||||
type: object
|
||||
properties:
|
||||
@@ -986,6 +1071,26 @@ components:
|
||||
name:
|
||||
type: string
|
||||
example: 'English'
|
||||
Collection:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
example: 123
|
||||
name:
|
||||
type: string
|
||||
example: A Movie Collection
|
||||
overview:
|
||||
type: string
|
||||
example: Overview of collection
|
||||
posterPath:
|
||||
type: string
|
||||
backdropPath:
|
||||
type: string
|
||||
parts:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/MovieResult'
|
||||
securitySchemes:
|
||||
cookieAuth:
|
||||
type: apiKey
|
||||
@@ -997,6 +1102,26 @@ components:
|
||||
name: X-Api-Key
|
||||
|
||||
paths:
|
||||
/status:
|
||||
get:
|
||||
summary: Return Overseerr version
|
||||
description: Returns the current Overseerr version in JSON format
|
||||
security: []
|
||||
tags:
|
||||
- public
|
||||
responses:
|
||||
'200':
|
||||
description: Returned version
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
example: 1.0.0
|
||||
commitTag:
|
||||
type: string
|
||||
/settings/main:
|
||||
get:
|
||||
summary: Returns main settings
|
||||
@@ -1488,6 +1613,21 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/NotificationEmailSettings'
|
||||
/settings/notifications/email/test:
|
||||
post:
|
||||
summary: Test the provided email settings
|
||||
description: Sends a test notification to the email agent
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/NotificationEmailSettings'
|
||||
responses:
|
||||
'204':
|
||||
description: Test notification attempted
|
||||
/settings/notifications/discord:
|
||||
get:
|
||||
summary: Return current discord notification settings
|
||||
@@ -1519,6 +1659,113 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DiscordSettings'
|
||||
/settings/notifications/discord/test:
|
||||
post:
|
||||
summary: Test the provided discord settings
|
||||
description: Sends a test notification to the discord agent
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DiscordSettings'
|
||||
responses:
|
||||
'204':
|
||||
description: Test notification attempted
|
||||
/settings/notifications/telegram:
|
||||
get:
|
||||
summary: Return current telegram notification settings
|
||||
description: Returns current telegram notification settings in JSON format
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
'200':
|
||||
description: Returned telegram settings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TelegramSettings'
|
||||
post:
|
||||
summary: Update telegram notification settings
|
||||
description: Update current telegram notification settings with provided values
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TelegramSettings'
|
||||
responses:
|
||||
'200':
|
||||
description: 'Values were sucessfully updated'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TelegramSettings'
|
||||
/settings/notifications/telegram/test:
|
||||
post:
|
||||
summary: Test the provided telegram settings
|
||||
description: Sends a test notification to the telegram agent
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TelegramSettings'
|
||||
responses:
|
||||
'204':
|
||||
description: Test notification attempted
|
||||
/settings/notifications/slack:
|
||||
get:
|
||||
summary: Return current slack notification settings
|
||||
description: Returns current slack notification settings in JSON format
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
'200':
|
||||
description: Returned slack settings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SlackSettings'
|
||||
post:
|
||||
summary: Update slack notification settings
|
||||
description: Update current slack notification settings with provided values
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SlackSettings'
|
||||
responses:
|
||||
'200':
|
||||
description: 'Values were sucessfully updated'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SlackSettings'
|
||||
/settings/notifications/slack/test:
|
||||
post:
|
||||
summary: Test the provided slack settings
|
||||
description: Sends a test notification to the slack agent
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SlackSettings'
|
||||
responses:
|
||||
'204':
|
||||
description: Test notification attempted
|
||||
/settings/about:
|
||||
get:
|
||||
summary: Return current about stats
|
||||
@@ -1640,6 +1887,25 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/User'
|
||||
/user/import-from-plex:
|
||||
post:
|
||||
summary: Imports all users from Plex
|
||||
description: |
|
||||
Requests users from the Plex Server and creates a new user for each of them
|
||||
|
||||
Requires the `MANAGE_USERS` permission.
|
||||
tags:
|
||||
- users
|
||||
responses:
|
||||
'201':
|
||||
description: A list of the newly created users
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/User'
|
||||
|
||||
/user/{userId}:
|
||||
get:
|
||||
summary: Retrieve a user by ID
|
||||
@@ -2079,6 +2345,30 @@ paths:
|
||||
responses:
|
||||
'204':
|
||||
description: Succesfully removed request
|
||||
/request/{requestId}/retry:
|
||||
post:
|
||||
summary: Retry a failed request
|
||||
description: |
|
||||
Retries a request by resending requests to Sonarr or Radarr
|
||||
|
||||
Requires the `MANAGE_REQUESTS` permission or `ADMIN`
|
||||
tags:
|
||||
- request
|
||||
parameters:
|
||||
- in: path
|
||||
name: requestId
|
||||
description: Request ID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Retry triggered
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MediaRequest'
|
||||
/request/{requestId}/{status}:
|
||||
get:
|
||||
summary: Update a requests status
|
||||
@@ -2573,6 +2863,31 @@ paths:
|
||||
responses:
|
||||
'204':
|
||||
description: Succesfully removed media item
|
||||
/collection/{collectionId}:
|
||||
get:
|
||||
summary: Request collection details
|
||||
description: Returns back full collection details in JSON format
|
||||
tags:
|
||||
- collection
|
||||
parameters:
|
||||
- in: path
|
||||
name: collectionId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
example: 537982
|
||||
- in: query
|
||||
name: language
|
||||
schema:
|
||||
type: string
|
||||
example: en
|
||||
responses:
|
||||
'200':
|
||||
description: Collection details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Collection'
|
||||
|
||||
security:
|
||||
- cookieAuth: []
|
||||
|
||||
40
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "overseerr",
|
||||
"version": "1.11.0",
|
||||
"version": "1.15.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node --files --project server/tsconfig.json server/index.ts",
|
||||
@@ -25,39 +25,41 @@
|
||||
"cookie-parser": "^1.4.5",
|
||||
"email-templates": "^8.0.2",
|
||||
"express": "^4.17.1",
|
||||
"express-openapi-validator": "^4.8.0",
|
||||
"express-openapi-validator": "^4.9.4",
|
||||
"express-session": "^1.17.1",
|
||||
"formik": "^2.2.5",
|
||||
"formik": "^2.2.6",
|
||||
"intl": "^1.2.5",
|
||||
"lodash": "^4.17.20",
|
||||
"next": "^10.0.3",
|
||||
"node-schedule": "^1.3.2",
|
||||
"nodemailer": "^6.4.16",
|
||||
"nodemailer": "^6.4.17",
|
||||
"nookies": "^2.5.0",
|
||||
"plex-api": "^5.3.1",
|
||||
"pug": "^3.0.0",
|
||||
"react": "17.0.1",
|
||||
"react-dom": "17.0.1",
|
||||
"react-intersection-observer": "^8.31.0",
|
||||
"react-intl": "^5.10.6",
|
||||
"react-intl": "^5.10.9",
|
||||
"react-markdown": "^5.0.3",
|
||||
"react-spring": "^8.0.27",
|
||||
"react-toast-notifications": "^2.4.0",
|
||||
"react-transition-group": "^4.4.1",
|
||||
"react-truncate-markup": "^5.0.1",
|
||||
"react-use-clipboard": "1.0.2",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"sqlite3": "^5.0.0",
|
||||
"swagger-ui-express": "^4.1.5",
|
||||
"swr": "^0.3.9",
|
||||
"swr": "^0.3.11",
|
||||
"typeorm": "^0.2.29",
|
||||
"uuid": "^8.3.2",
|
||||
"winston": "^3.3.3",
|
||||
"winston-daily-rotate-file": "^4.5.0",
|
||||
"xml2js": "^0.4.23",
|
||||
"yamljs": "^0.3.0",
|
||||
"yup": "^0.32.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.12.8",
|
||||
"@babel/cli": "^7.12.10",
|
||||
"@commitlint/cli": "^11.0.0",
|
||||
"@commitlint/config-conventional": "^11.0.0",
|
||||
"@semantic-release/changelog": "^5.0.1",
|
||||
@@ -73,7 +75,7 @@
|
||||
"@types/express": "^4.17.9",
|
||||
"@types/express-session": "^1.17.0",
|
||||
"@types/lodash": "^4.14.165",
|
||||
"@types/node": "^14.14.11",
|
||||
"@types/node": "^14.14.14",
|
||||
"@types/node-schedule": "^1.3.1",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"@types/react": "^17.0.0",
|
||||
@@ -84,24 +86,24 @@
|
||||
"@types/uuid": "^8.3.0",
|
||||
"@types/xml2js": "^0.4.7",
|
||||
"@types/yamljs": "^0.2.31",
|
||||
"@types/yup": "^0.29.10",
|
||||
"@typescript-eslint/eslint-plugin": "^4.9.1",
|
||||
"@typescript-eslint/parser": "^4.9.1",
|
||||
"@types/yup": "^0.29.11",
|
||||
"@typescript-eslint/eslint-plugin": "^4.10.0",
|
||||
"@typescript-eslint/parser": "^4.10.0",
|
||||
"autoprefixer": "^9",
|
||||
"babel-plugin-react-intl": "^8.2.21",
|
||||
"babel-plugin-react-intl": "^8.2.22",
|
||||
"babel-plugin-react-intl-auto": "^3.3.0",
|
||||
"commitizen": "^4.2.2",
|
||||
"copyfiles": "^2.4.1",
|
||||
"cz-conventional-changelog": "^3.3.0",
|
||||
"eslint": "^7.15.0",
|
||||
"eslint-config-prettier": "^7.0.0",
|
||||
"eslint-plugin-formatjs": "^2.9.10",
|
||||
"eslint": "^7.16.0",
|
||||
"eslint-config-prettier": "^7.1.0",
|
||||
"eslint-plugin-formatjs": "^2.9.11",
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"eslint-plugin-prettier": "^3.2.0",
|
||||
"eslint-plugin-prettier": "^3.3.0",
|
||||
"eslint-plugin-react": "^7.21.5",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"extract-react-intl-messages": "^4.1.1",
|
||||
"husky": "^4.3.5",
|
||||
"husky": "^4.3.6",
|
||||
"lint-staged": "^10.5.3",
|
||||
"nodemon": "^2.0.6",
|
||||
"postcss": "^7",
|
||||
@@ -111,7 +113,7 @@
|
||||
"semantic-release-docker": "^2.2.0",
|
||||
"tailwindcss": "npm:@tailwindcss/postcss7-compat",
|
||||
"ts-node": "^9.1.1",
|
||||
"typescript": "^4.1.2"
|
||||
"typescript": "^4.1.3"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
@@ -169,7 +171,7 @@
|
||||
[
|
||||
"@semantic-release/exec",
|
||||
{
|
||||
"prepareCmd": "docker build -t sctx/overseerr ."
|
||||
"prepareCmd": "docker build --build-arg COMMIT_TAG=$GITHUB_SHA -t sctx/overseerr ."
|
||||
}
|
||||
],
|
||||
"semantic-release-docker",
|
||||
|
||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 382 KiB |
|
Before Width: | Height: | Size: 249 KiB After Width: | Height: | Size: 407 KiB |
BIN
public/images/rotate5.jpg
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
public/images/rotate6.jpg
Normal file
|
After Width: | Height: | Size: 421 KiB |
@@ -1 +1,20 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#1e2937","display":"standalone"}
|
||||
{
|
||||
"name": "Overseerr",
|
||||
"short_name": "Overseerr",
|
||||
"start_url": "./",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#2d3748",
|
||||
"background_color": "#2d3748",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
||||
@@ -56,6 +56,21 @@ interface FriendResponse {
|
||||
};
|
||||
}
|
||||
|
||||
interface UsersResponse {
|
||||
MediaContainer: {
|
||||
User: {
|
||||
$: {
|
||||
id: string;
|
||||
title: string;
|
||||
username: string;
|
||||
email: string;
|
||||
thumb: string;
|
||||
};
|
||||
Server: ServerResponse[];
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
class PlexTvAPI {
|
||||
private authToken: string;
|
||||
private axios: AxiosInstance;
|
||||
@@ -129,6 +144,18 @@ class PlexTvAPI {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async getUsers(): Promise<UsersResponse> {
|
||||
const response = await this.axios.get('/api/users', {
|
||||
transformResponse: [],
|
||||
responseType: 'text',
|
||||
});
|
||||
|
||||
const parsedXml = (await xml2js.parseStringPromise(
|
||||
response.data
|
||||
)) as UsersResponse;
|
||||
return parsedXml;
|
||||
}
|
||||
}
|
||||
|
||||
export default PlexTvAPI;
|
||||
|
||||
@@ -76,9 +76,9 @@ class RadarrAPI {
|
||||
}
|
||||
};
|
||||
|
||||
public addMovie = async (options: RadarrMovieOptions): Promise<void> => {
|
||||
public addMovie = async (options: RadarrMovieOptions): Promise<boolean> => {
|
||||
try {
|
||||
await this.axios.post<RadarrMovie>(`/movie`, {
|
||||
const response = await this.axios.post<RadarrMovie>(`/movie`, {
|
||||
title: options.title,
|
||||
qualityProfileId: options.qualityProfileId,
|
||||
profileId: options.profileId,
|
||||
@@ -92,6 +92,21 @@ class RadarrAPI {
|
||||
searchForMovie: options.searchNow,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data.id) {
|
||||
logger.info('Radarr accepted request', { label: 'Radarr' });
|
||||
logger.debug('Radarr add details', {
|
||||
label: 'Radarr',
|
||||
movie: response.data,
|
||||
});
|
||||
} else {
|
||||
logger.error('Failed to add movie to Radarr', {
|
||||
label: 'Radarr',
|
||||
options,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.',
|
||||
@@ -99,8 +114,13 @@ class RadarrAPI {
|
||||
label: 'Radarr',
|
||||
errorMessage: e.message,
|
||||
options,
|
||||
response: e?.response?.data,
|
||||
}
|
||||
);
|
||||
if (e?.response?.data?.[0]?.errorCode === 'MovieExistsValidator') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ class SonarrAPI {
|
||||
}
|
||||
}
|
||||
|
||||
public async addSeries(options: AddSeriesOptions): Promise<SonarrSeries> {
|
||||
public async addSeries(options: AddSeriesOptions): Promise<boolean> {
|
||||
try {
|
||||
const series = await this.getSeriesByTvdbId(options.tvdbid);
|
||||
|
||||
@@ -126,7 +126,7 @@ class SonarrAPI {
|
||||
|
||||
series.addOptions = {
|
||||
ignoreEpisodesWithFiles: true,
|
||||
searchForMissingEpisodes: true,
|
||||
searchForMissingEpisodes: options.searchNow,
|
||||
};
|
||||
|
||||
const newSeriesResponse = await this.axios.put<SonarrSeries>(
|
||||
@@ -134,7 +134,23 @@ class SonarrAPI {
|
||||
series
|
||||
);
|
||||
|
||||
return newSeriesResponse.data;
|
||||
if (newSeriesResponse.data.id) {
|
||||
logger.info('Sonarr accepted request. Updated existing series', {
|
||||
label: 'Sonarr',
|
||||
});
|
||||
logger.debug('Sonarr add details', {
|
||||
label: 'Sonarr',
|
||||
movie: newSeriesResponse.data,
|
||||
});
|
||||
} else {
|
||||
logger.error('Failed to add movie to Sonarr', {
|
||||
label: 'Sonarr',
|
||||
options,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const createdSeriesResponse = await this.axios.post<SonarrSeries>(
|
||||
@@ -162,14 +178,29 @@ class SonarrAPI {
|
||||
} as Partial<SonarrSeries>
|
||||
);
|
||||
|
||||
return createdSeriesResponse.data;
|
||||
if (createdSeriesResponse.data.id) {
|
||||
logger.info('Sonarr accepted request', { label: 'Sonarr' });
|
||||
logger.debug('Sonarr add details', {
|
||||
label: 'Sonarr',
|
||||
movie: createdSeriesResponse.data,
|
||||
});
|
||||
} else {
|
||||
logger.error('Failed to add movie to Sonarr', {
|
||||
label: 'Sonarr',
|
||||
options,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong adding a series to Sonarr', {
|
||||
label: 'Sonarr API',
|
||||
errorMessage: e.message,
|
||||
error: e,
|
||||
response: e?.response?.data,
|
||||
});
|
||||
throw new Error('Failed to add series');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ export interface TmdbTvResult extends TmdbMediaResult {
|
||||
name: string;
|
||||
original_name: string;
|
||||
origin_country: string[];
|
||||
first_air_Date: string;
|
||||
first_air_date: string;
|
||||
}
|
||||
|
||||
export interface TmdbPersonResult {
|
||||
@@ -190,7 +190,30 @@ export interface TmdbMovieDetails {
|
||||
cast: TmdbCreditCast[];
|
||||
crew: TmdbCreditCrew[];
|
||||
};
|
||||
belongs_to_collection?: {
|
||||
id: number;
|
||||
name: string;
|
||||
poster_path?: string;
|
||||
backdrop_path?: string;
|
||||
};
|
||||
external_ids: TmdbExternalIds;
|
||||
videos: TmdbVideoResult;
|
||||
}
|
||||
|
||||
export interface TmdbVideo {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
site: 'YouTube';
|
||||
size: number;
|
||||
type:
|
||||
| 'Clip'
|
||||
| 'Teaser'
|
||||
| 'Trailer'
|
||||
| 'Featurette'
|
||||
| 'Opening Credits'
|
||||
| 'Behind the Scenes'
|
||||
| 'Bloopers';
|
||||
}
|
||||
|
||||
export interface TmdbTvEpisodeResult {
|
||||
@@ -278,6 +301,11 @@ export interface TmdbTvDetails {
|
||||
keywords: {
|
||||
results: TmdbKeyword[];
|
||||
};
|
||||
videos: TmdbVideoResult;
|
||||
}
|
||||
|
||||
export interface TmdbVideoResult {
|
||||
results: TmdbVideo[];
|
||||
}
|
||||
|
||||
export interface TmdbKeyword {
|
||||
@@ -344,6 +372,15 @@ export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult {
|
||||
external_ids: TmdbExternalIds;
|
||||
}
|
||||
|
||||
export interface TmdbCollection {
|
||||
id: number;
|
||||
name: string;
|
||||
overview?: string;
|
||||
poster_path?: string;
|
||||
backdrop_path?: string;
|
||||
parts: TmdbMovieResult[];
|
||||
}
|
||||
|
||||
class TheMovieDb {
|
||||
private apiKey = 'db55323b8d3e4154498498a75642b381';
|
||||
private axios: AxiosInstance;
|
||||
@@ -374,7 +411,12 @@ class TheMovieDb {
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to search multi: ${e.message}`);
|
||||
return {
|
||||
page: 1,
|
||||
results: [],
|
||||
total_pages: 1,
|
||||
total_results: 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -433,7 +475,10 @@ class TheMovieDb {
|
||||
const response = await this.axios.get<TmdbMovieDetails>(
|
||||
`/movie/${movieId}`,
|
||||
{
|
||||
params: { language, append_to_response: 'credits,external_ids' },
|
||||
params: {
|
||||
language,
|
||||
append_to_response: 'credits,external_ids,videos',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -454,7 +499,7 @@ class TheMovieDb {
|
||||
const response = await this.axios.get<TmdbTvDetails>(`/tv/${tvId}`, {
|
||||
params: {
|
||||
language,
|
||||
append_to_response: 'credits,external_ids,keywords',
|
||||
append_to_response: 'credits,external_ids,keywords,videos',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -861,6 +906,29 @@ class TheMovieDb {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getCollection({
|
||||
collectionId,
|
||||
language = 'en-US',
|
||||
}: {
|
||||
collectionId: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbCollection> {
|
||||
try {
|
||||
const response = await this.axios.get<TmdbCollection>(
|
||||
`/collection/${collectionId}`,
|
||||
{
|
||||
params: {
|
||||
language,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TheMovieDb;
|
||||
|
||||
@@ -40,12 +40,15 @@ class Media {
|
||||
}
|
||||
}
|
||||
|
||||
public static async getMedia(id: number): Promise<Media | undefined> {
|
||||
public static async getMedia(
|
||||
id: number,
|
||||
mediaType: MediaType
|
||||
): Promise<Media | undefined> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
try {
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { tmdbId: id },
|
||||
where: { tmdbId: id, mediaType },
|
||||
relations: ['requests'],
|
||||
});
|
||||
|
||||
@@ -62,7 +65,7 @@ class Media {
|
||||
@Column({ type: 'varchar' })
|
||||
public mediaType: MediaType;
|
||||
|
||||
@Column({ unique: true })
|
||||
@Column()
|
||||
@Index()
|
||||
public tmdbId: number;
|
||||
|
||||
@@ -70,7 +73,7 @@ class Media {
|
||||
@Index()
|
||||
public tvdbId?: number;
|
||||
|
||||
@Column({ unique: true, nullable: true })
|
||||
@Column({ nullable: true })
|
||||
@Index()
|
||||
public imdbId?: string;
|
||||
|
||||
@@ -92,6 +95,9 @@ class Media {
|
||||
@UpdateDateColumn()
|
||||
public updatedAt: Date;
|
||||
|
||||
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||
public lastSeasonChange: Date;
|
||||
|
||||
constructor(init?: Partial<Media>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,12 @@ export class MediaRequest {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
@AfterUpdate()
|
||||
@AfterInsert()
|
||||
public async sendMedia(): Promise<void> {
|
||||
await Promise.all([this._sendToRadarr(), this._sendToSonarr()]);
|
||||
}
|
||||
|
||||
@AfterInsert()
|
||||
private async _notifyNewRequest() {
|
||||
if (this.status === MediaRequestStatus.PENDING) {
|
||||
@@ -88,6 +94,7 @@ export class MediaRequest {
|
||||
message: movie.overview,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
notifyUser: this.requestedBy,
|
||||
media,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -98,6 +105,7 @@ export class MediaRequest {
|
||||
message: tv.overview,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||
notifyUser: this.requestedBy,
|
||||
media,
|
||||
extra: [
|
||||
{
|
||||
name: 'Seasons',
|
||||
@@ -136,6 +144,7 @@ export class MediaRequest {
|
||||
message: movie.overview,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
notifyUser: this.requestedBy,
|
||||
media,
|
||||
});
|
||||
} else if (this.media.mediaType === MediaType.TV) {
|
||||
const tv = await tmdb.getTvShow({ tvId: this.media.tmdbId });
|
||||
@@ -144,6 +153,7 @@ export class MediaRequest {
|
||||
message: tv.overview,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||
notifyUser: this.requestedBy,
|
||||
media,
|
||||
extra: [
|
||||
{
|
||||
name: 'Seasons',
|
||||
@@ -159,7 +169,7 @@ export class MediaRequest {
|
||||
|
||||
@AfterUpdate()
|
||||
@AfterInsert()
|
||||
private async _updateParentStatus() {
|
||||
public async updateParentStatus(): Promise<void> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: this.media.id },
|
||||
@@ -225,14 +235,13 @@ export class MediaRequest {
|
||||
}
|
||||
}
|
||||
|
||||
@AfterUpdate()
|
||||
@AfterInsert()
|
||||
private async _sendToRadarr() {
|
||||
if (
|
||||
this.status === MediaRequestStatus.APPROVED &&
|
||||
this.type === MediaType.MOVIE
|
||||
) {
|
||||
try {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const settings = getSettings();
|
||||
if (settings.radarr.length === 0 && !settings.radarr[0]) {
|
||||
logger.info(
|
||||
@@ -264,17 +273,49 @@ export class MediaRequest {
|
||||
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
|
||||
|
||||
// Run this asynchronously so we don't wait for it on the UI side
|
||||
radarr.addMovie({
|
||||
profileId: radarrSettings.activeProfileId,
|
||||
qualityProfileId: radarrSettings.activeProfileId,
|
||||
rootFolderPath: radarrSettings.activeDirectory,
|
||||
minimumAvailability: radarrSettings.minimumAvailability,
|
||||
title: movie.title,
|
||||
tmdbId: movie.id,
|
||||
year: Number(movie.release_date.slice(0, 4)),
|
||||
monitored: true,
|
||||
searchNow: true,
|
||||
});
|
||||
radarr
|
||||
.addMovie({
|
||||
profileId: radarrSettings.activeProfileId,
|
||||
qualityProfileId: radarrSettings.activeProfileId,
|
||||
rootFolderPath: radarrSettings.activeDirectory,
|
||||
minimumAvailability: radarrSettings.minimumAvailability,
|
||||
title: movie.title,
|
||||
tmdbId: movie.id,
|
||||
year: Number(movie.release_date.slice(0, 4)),
|
||||
monitored: true,
|
||||
searchNow: true,
|
||||
})
|
||||
.then(async (success) => {
|
||||
if (!success) {
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: this.media.id },
|
||||
});
|
||||
if (!media) {
|
||||
logger.error('Media not present');
|
||||
return;
|
||||
}
|
||||
media.status = MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
logger.warn(
|
||||
'Newly added movie request failed to add to Radarr, marking as unknown',
|
||||
{
|
||||
label: 'Media Request',
|
||||
}
|
||||
);
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOneOrFail({
|
||||
select: ['id', 'plexToken'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
|
||||
subject: movie.title,
|
||||
message: 'Movie failed to add to Radarr',
|
||||
notifyUser: admin,
|
||||
media,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
logger.info('Sent request to Radarr', { label: 'Media Request' });
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
@@ -284,8 +325,6 @@ export class MediaRequest {
|
||||
}
|
||||
}
|
||||
|
||||
@AfterUpdate()
|
||||
@AfterInsert()
|
||||
private async _sendToSonarr() {
|
||||
if (
|
||||
this.status === MediaRequestStatus.APPROVED &&
|
||||
@@ -348,23 +387,55 @@ export class MediaRequest {
|
||||
}
|
||||
|
||||
// Run this asynchronously so we don't wait for it on the UI side
|
||||
sonarr.addSeries({
|
||||
profileId:
|
||||
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
|
||||
? sonarrSettings.activeAnimeProfileId
|
||||
: sonarrSettings.activeProfileId,
|
||||
rootFolderPath:
|
||||
seriesType === 'anime' && sonarrSettings.activeAnimeDirectory
|
||||
? sonarrSettings.activeAnimeDirectory
|
||||
: sonarrSettings.activeDirectory,
|
||||
title: series.name,
|
||||
tvdbid: series.external_ids.tvdb_id,
|
||||
seasons: this.seasons.map((season) => season.seasonNumber),
|
||||
seasonFolder: sonarrSettings.enableSeasonFolders,
|
||||
seriesType,
|
||||
monitored: true,
|
||||
searchNow: true,
|
||||
});
|
||||
sonarr
|
||||
.addSeries({
|
||||
profileId:
|
||||
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
|
||||
? sonarrSettings.activeAnimeProfileId
|
||||
: sonarrSettings.activeProfileId,
|
||||
rootFolderPath:
|
||||
seriesType === 'anime' && sonarrSettings.activeAnimeDirectory
|
||||
? sonarrSettings.activeAnimeDirectory
|
||||
: sonarrSettings.activeDirectory,
|
||||
title: series.name,
|
||||
tvdbid: series.external_ids.tvdb_id,
|
||||
seasons: this.seasons.map((season) => season.seasonNumber),
|
||||
seasonFolder: sonarrSettings.enableSeasonFolders,
|
||||
seriesType,
|
||||
monitored: true,
|
||||
searchNow: true,
|
||||
})
|
||||
.then(async (success) => {
|
||||
if (!success) {
|
||||
media.status = MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
logger.warn(
|
||||
'Newly added series request failed to add to Sonarr, marking as unknown',
|
||||
{
|
||||
label: 'Media Request',
|
||||
}
|
||||
);
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOneOrFail({
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
|
||||
subject: series.name,
|
||||
message: 'Series failed to add to Sonarr',
|
||||
notifyUser: admin,
|
||||
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(', '),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
logger.info('Sent request to Sonarr', { label: 'Media Request' });
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
|
||||
@@ -17,7 +17,9 @@ 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 TelegramAgent from './lib/notifications/agents/telegram';
|
||||
import { getAppVersion } from './utils/appVersion';
|
||||
import SlackAgent from './lib/notifications/agents/slack';
|
||||
|
||||
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
|
||||
|
||||
@@ -29,12 +31,25 @@ const handle = app.getRequestHandler();
|
||||
app
|
||||
.prepare()
|
||||
.then(async () => {
|
||||
await createConnection();
|
||||
const dbConnection = await createConnection();
|
||||
|
||||
// Run migrations in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
await dbConnection.query('PRAGMA foreign_keys=OFF');
|
||||
await dbConnection.runMigrations();
|
||||
await dbConnection.query('PRAGMA foreign_keys=ON');
|
||||
}
|
||||
|
||||
// Load Settings
|
||||
const settings = getSettings().load();
|
||||
|
||||
// Register Notification Agents
|
||||
notificationManager.registerAgents([new DiscordAgent(), new EmailAgent()]);
|
||||
notificationManager.registerAgents([
|
||||
new DiscordAgent(),
|
||||
new EmailAgent(),
|
||||
new SlackAgent(),
|
||||
new TelegramAgent(),
|
||||
]);
|
||||
|
||||
// Start Jobs
|
||||
startJobs();
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { getRepository } from 'typeorm';
|
||||
import { User } from '../../entity/User';
|
||||
import PlexAPI, { PlexLibraryItem } from '../../api/plexapi';
|
||||
import TheMovieDb, { TmdbTvDetails } from '../../api/themoviedb';
|
||||
import TheMovieDb, {
|
||||
TmdbMovieDetails,
|
||||
TmdbTvDetails,
|
||||
} from '../../api/themoviedb';
|
||||
import Media from '../../entity/Media';
|
||||
import { MediaStatus, MediaType } from '../../constants/media';
|
||||
import logger from '../../logger';
|
||||
@@ -14,7 +17,7 @@ const UPDATE_RATE = 4 * 1000;
|
||||
|
||||
const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
|
||||
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
|
||||
const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/);
|
||||
const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)|hama:\/\/tvdb-([0-9]+)/);
|
||||
const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/);
|
||||
const plexRegex = new RegExp(/plex:\/\//);
|
||||
|
||||
@@ -41,11 +44,11 @@ class JobPlexSync {
|
||||
this.isRecentOnly = isRecentOnly ?? false;
|
||||
}
|
||||
|
||||
private async getExisting(tmdbId: number) {
|
||||
private async getExisting(tmdbId: number, mediaType: MediaType) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const existing = await mediaRepository.findOne({
|
||||
where: { tmdbId: tmdbId },
|
||||
where: { tmdbId: tmdbId, mediaType },
|
||||
});
|
||||
|
||||
return existing;
|
||||
@@ -75,7 +78,10 @@ class JobPlexSync {
|
||||
}
|
||||
});
|
||||
|
||||
const existing = await this.getExisting(newMedia.tmdbId);
|
||||
const existing = await this.getExisting(
|
||||
newMedia.tmdbId,
|
||||
MediaType.MOVIE
|
||||
);
|
||||
|
||||
if (existing && existing.status === MediaStatus.AVAILABLE) {
|
||||
this.log(`Title exists and is already available ${metadata.title}`);
|
||||
@@ -93,40 +99,58 @@ class JobPlexSync {
|
||||
this.log(`Saved ${plexitem.title}`);
|
||||
}
|
||||
} else {
|
||||
const matchedid = plexitem.guid.match(/imdb:\/\/(tt[0-9]+)/);
|
||||
let tmdbMovieId: number | undefined;
|
||||
let tmdbMovie: TmdbMovieDetails | undefined;
|
||||
|
||||
if (matchedid?.[1]) {
|
||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
||||
imdbId: matchedid[1],
|
||||
const imdbMatch = plexitem.guid.match(imdbRegex);
|
||||
const tmdbMatch = plexitem.guid.match(tmdbShowRegex);
|
||||
|
||||
if (imdbMatch) {
|
||||
tmdbMovie = await this.tmdb.getMovieByImdbId({
|
||||
imdbId: imdbMatch[1],
|
||||
});
|
||||
tmdbMovieId = tmdbMovie.id;
|
||||
} else if (tmdbMatch) {
|
||||
tmdbMovieId = Number(tmdbMatch[1]);
|
||||
}
|
||||
|
||||
const existing = await this.getExisting(tmdbMovie.id);
|
||||
if (existing && existing.status === MediaStatus.AVAILABLE) {
|
||||
this.log(`Title exists and is already available ${plexitem.title}`);
|
||||
} else if (existing && existing.status !== MediaStatus.AVAILABLE) {
|
||||
existing.status = MediaStatus.AVAILABLE;
|
||||
await mediaRepository.save(existing);
|
||||
this.log(
|
||||
`Request for ${plexitem.title} exists. Setting status AVAILABLE`,
|
||||
'info'
|
||||
);
|
||||
} else if (tmdbMovie) {
|
||||
const newMedia = new Media();
|
||||
newMedia.imdbId = tmdbMovie.external_ids.imdb_id;
|
||||
newMedia.tmdbId = tmdbMovie.id;
|
||||
newMedia.status = MediaStatus.AVAILABLE;
|
||||
newMedia.mediaType = MediaType.MOVIE;
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${tmdbMovie.title}`);
|
||||
if (!tmdbMovieId) {
|
||||
throw new Error('Unable to find TMDB ID');
|
||||
}
|
||||
|
||||
const existing = await this.getExisting(tmdbMovieId, MediaType.MOVIE);
|
||||
if (existing && existing.status === MediaStatus.AVAILABLE) {
|
||||
this.log(`Title exists and is already available ${plexitem.title}`);
|
||||
} else if (existing && existing.status !== MediaStatus.AVAILABLE) {
|
||||
existing.status = MediaStatus.AVAILABLE;
|
||||
await mediaRepository.save(existing);
|
||||
this.log(
|
||||
`Request for ${plexitem.title} exists. Setting status AVAILABLE`,
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
// If we have a tmdb movie guid but it didn't already exist, only then
|
||||
// do we request the movie from tmdb (to reduce api requests)
|
||||
if (!tmdbMovie) {
|
||||
tmdbMovie = await this.tmdb.getMovie({ movieId: tmdbMovieId });
|
||||
}
|
||||
const newMedia = new Media();
|
||||
newMedia.imdbId = tmdbMovie.external_ids.imdb_id;
|
||||
newMedia.tmdbId = tmdbMovie.id;
|
||||
newMedia.status = MediaStatus.AVAILABLE;
|
||||
newMedia.mediaType = MediaType.MOVIE;
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${tmdbMovie.title}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.log(
|
||||
`Failed to process plex item. ratingKey: ${
|
||||
plexitem.parentRatingKey ?? plexitem.ratingKey
|
||||
}`,
|
||||
'error'
|
||||
`Failed to process plex item. ratingKey: ${plexitem.ratingKey}`,
|
||||
'error',
|
||||
{
|
||||
errorMessage: e.message,
|
||||
plexitem,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -163,12 +187,16 @@ class JobPlexSync {
|
||||
if (tvShow && metadata) {
|
||||
// Lets get the available seasons from plex
|
||||
const seasons = tvShow.seasons;
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { tmdbId: tvShow.id, mediaType: MediaType.TV },
|
||||
});
|
||||
const media = await this.getExisting(tvShow.id, MediaType.TV);
|
||||
|
||||
const newSeasons: Season[] = [];
|
||||
|
||||
const currentSeasonAvailable = (
|
||||
media?.seasons.filter(
|
||||
(season) => season.status === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
seasons.forEach((season) => {
|
||||
const matchedPlexSeason = metadata.Children?.Metadata.find(
|
||||
(md) => Number(md.index) === season.season_number
|
||||
@@ -219,6 +247,25 @@ class JobPlexSync {
|
||||
if (media) {
|
||||
// Update existing
|
||||
media.seasons = [...media.seasons, ...newSeasons];
|
||||
|
||||
const newSeasonAvailable = (
|
||||
media.seasons.filter(
|
||||
(season) => season.status === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
// If at least one new season has become available, update
|
||||
// the lastSeasonChange field so we can trigger notifications
|
||||
if (newSeasonAvailable > currentSeasonAvailable) {
|
||||
this.log(
|
||||
`Detected ${
|
||||
newSeasonAvailable - currentSeasonAvailable
|
||||
} new season(s) for ${tvShow.name}`,
|
||||
'debug'
|
||||
);
|
||||
media.lastSeasonChange = new Date();
|
||||
}
|
||||
|
||||
media.status = isAllSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: MediaStatus.PARTIALLY_AVAILABLE;
|
||||
|
||||
@@ -20,7 +20,7 @@ export const startJobs = (): void => {
|
||||
jobPlexRecentSync.run();
|
||||
}),
|
||||
});
|
||||
// Run full plex sync every 6 hours
|
||||
// Run full plex sync every 24 hours
|
||||
scheduledJobs.push({
|
||||
name: 'Plex Full Library Sync',
|
||||
job: schedule.scheduleJob('0 0 3 * * *', () => {
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { Notification } from '..';
|
||||
import Media from '../../../entity/Media';
|
||||
import { User } from '../../../entity/User';
|
||||
import { NotificationAgentConfig } from '../../settings';
|
||||
|
||||
export interface NotificationPayload {
|
||||
subject: string;
|
||||
notifyUser: User;
|
||||
media?: Media;
|
||||
image?: string;
|
||||
message?: string;
|
||||
extra?: { name: string; value: string }[];
|
||||
}
|
||||
|
||||
export abstract class BaseAgent<T extends NotificationAgentConfig> {
|
||||
protected settings?: T;
|
||||
public constructor(settings?: T) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
protected abstract getSettings(): T;
|
||||
}
|
||||
|
||||
export interface NotificationAgent {
|
||||
shouldSend(type: Notification): boolean;
|
||||
send(type: Notification, payload: NotificationPayload): Promise<boolean>;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import axios from 'axios';
|
||||
import { Notification } from '..';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings } from '../../settings';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { getSettings, NotificationAgentDiscord } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
|
||||
enum EmbedColors {
|
||||
DEFAULT = 0,
|
||||
@@ -37,6 +37,11 @@ interface DiscordImageEmbed {
|
||||
width?: number;
|
||||
}
|
||||
|
||||
interface Field {
|
||||
name: string;
|
||||
value: string;
|
||||
inline?: boolean;
|
||||
}
|
||||
interface DiscordRichEmbed {
|
||||
title?: string;
|
||||
type?: 'rich'; // Always rich for webhooks
|
||||
@@ -61,11 +66,7 @@ interface DiscordRichEmbed {
|
||||
icon_url?: string;
|
||||
proxy_icon_url?: string;
|
||||
};
|
||||
fields?: {
|
||||
name: string;
|
||||
value: string;
|
||||
inline?: boolean;
|
||||
}[];
|
||||
fields?: Field[];
|
||||
}
|
||||
|
||||
interface DiscordWebhookPayload {
|
||||
@@ -75,26 +76,103 @@ interface DiscordWebhookPayload {
|
||||
tts: boolean;
|
||||
}
|
||||
|
||||
class DiscordAgent implements NotificationAgent {
|
||||
class DiscordAgent
|
||||
extends BaseAgent<NotificationAgentDiscord>
|
||||
implements NotificationAgent {
|
||||
protected getSettings(): NotificationAgentDiscord {
|
||||
if (this.settings) {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
|
||||
return settings.notifications.agents.discord;
|
||||
}
|
||||
|
||||
public buildEmbed(
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): DiscordRichEmbed {
|
||||
let color = EmbedColors.DEFAULT;
|
||||
let status = 'Unknown';
|
||||
const settings = getSettings();
|
||||
let color = EmbedColors.DARK_PURPLE;
|
||||
|
||||
const fields: Field[] = [];
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
color = EmbedColors.ORANGE;
|
||||
status = 'Pending Approval';
|
||||
fields.push(
|
||||
{
|
||||
name: 'Requested By',
|
||||
value: payload.notifyUser.username ?? '',
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: 'Status',
|
||||
value: 'Pending Approval',
|
||||
inline: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (settings.main.applicationUrl) {
|
||||
fields.push({
|
||||
name: 'View Media',
|
||||
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
color = EmbedColors.PURPLE;
|
||||
status = 'Processing Request';
|
||||
fields.push(
|
||||
{
|
||||
name: 'Requested By',
|
||||
value: payload.notifyUser.username ?? '',
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: 'Status',
|
||||
value: 'Processing Request',
|
||||
inline: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (settings.main.applicationUrl) {
|
||||
fields.push({
|
||||
name: 'View Media',
|
||||
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
color = EmbedColors.GREEN;
|
||||
status = 'Available';
|
||||
fields.push(
|
||||
{
|
||||
name: 'Requested By',
|
||||
value: payload.notifyUser.username ?? '',
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: 'Status',
|
||||
value: 'Available',
|
||||
inline: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (settings.main.applicationUrl) {
|
||||
fields.push({
|
||||
name: 'View Media',
|
||||
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
color = EmbedColors.RED;
|
||||
if (settings.main.applicationUrl) {
|
||||
fields.push({
|
||||
name: 'View Media',
|
||||
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -103,18 +181,9 @@ class DiscordAgent implements NotificationAgent {
|
||||
description: payload.message,
|
||||
color,
|
||||
timestamp: new Date().toISOString(),
|
||||
author: { name: 'Overseerr' },
|
||||
author: { name: 'Overseerr', url: settings.main.applicationUrl },
|
||||
fields: [
|
||||
{
|
||||
name: 'Requested By',
|
||||
value: payload.notifyUser.username ?? '',
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: 'Status',
|
||||
value: status,
|
||||
inline: true,
|
||||
},
|
||||
...fields,
|
||||
// If we have extra data, map it to fields for discord notifications
|
||||
...(payload.extra ?? []).map((extra) => ({
|
||||
name: extra.name,
|
||||
@@ -127,14 +196,11 @@ class DiscordAgent implements NotificationAgent {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Add checking for type here once we add notification type filters for agents
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public shouldSend(_type: Notification): boolean {
|
||||
const settings = getSettings();
|
||||
|
||||
public shouldSend(type: Notification): boolean {
|
||||
if (
|
||||
settings.notifications.agents.discord?.enabled &&
|
||||
settings.notifications.agents.discord?.options?.webhookUrl
|
||||
this.getSettings().enabled &&
|
||||
this.getSettings().options.webhookUrl &&
|
||||
hasNotificationType(type, this.getSettings().types)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -146,11 +212,9 @@ class DiscordAgent implements NotificationAgent {
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
const settings = getSettings();
|
||||
logger.debug('Sending discord notification', { label: 'Notifications' });
|
||||
try {
|
||||
const webhookUrl =
|
||||
settings.notifications.agents.discord?.options?.webhookUrl;
|
||||
const webhookUrl = this.getSettings().options.webhookUrl;
|
||||
|
||||
if (!webhookUrl) {
|
||||
return false;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { Notification } from '..';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import path from 'path';
|
||||
import { getSettings } from '../../settings';
|
||||
import { getSettings, NotificationAgentEmail } from '../../settings';
|
||||
import nodemailer from 'nodemailer';
|
||||
import Email from 'email-templates';
|
||||
import logger from '../../../logger';
|
||||
@@ -9,13 +9,26 @@ import { getRepository } from 'typeorm';
|
||||
import { User } from '../../../entity/User';
|
||||
import { Permission } from '../../permissions';
|
||||
|
||||
class EmailAgent implements NotificationAgent {
|
||||
// TODO: Add checking for type here once we add notification type filters for agents
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public shouldSend(_type: Notification): boolean {
|
||||
class EmailAgent
|
||||
extends BaseAgent<NotificationAgentEmail>
|
||||
implements NotificationAgent {
|
||||
protected getSettings(): NotificationAgentEmail {
|
||||
if (this.settings) {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
|
||||
if (settings.notifications.agents.email.enabled) {
|
||||
return settings.notifications.agents.email;
|
||||
}
|
||||
|
||||
public shouldSend(type: Notification): boolean {
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (
|
||||
settings.enabled &&
|
||||
hasNotificationType(type, this.getSettings().types)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -23,12 +36,17 @@ class EmailAgent implements NotificationAgent {
|
||||
}
|
||||
|
||||
private getSmtpTransport() {
|
||||
const emailSettings = getSettings().notifications.agents.email.options;
|
||||
const emailSettings = this.getSettings().options;
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host: emailSettings.smtpHost,
|
||||
port: emailSettings.smtpPort,
|
||||
secure: emailSettings.secure,
|
||||
tls: emailSettings.allowSelfSigned
|
||||
? {
|
||||
rejectUnauthorized: false,
|
||||
}
|
||||
: undefined,
|
||||
auth:
|
||||
emailSettings.authUser && emailSettings.authPass
|
||||
? {
|
||||
@@ -40,10 +58,13 @@ class EmailAgent implements NotificationAgent {
|
||||
}
|
||||
|
||||
private getNewEmail() {
|
||||
const settings = getSettings().notifications.agents.email;
|
||||
const settings = this.getSettings();
|
||||
return new Email({
|
||||
message: {
|
||||
from: settings.options.emailFrom,
|
||||
from: {
|
||||
name: settings.options.senderName,
|
||||
address: settings.options.emailFrom,
|
||||
},
|
||||
},
|
||||
send: true,
|
||||
transport: this.getSmtpTransport(),
|
||||
@@ -51,7 +72,8 @@ class EmailAgent implements NotificationAgent {
|
||||
}
|
||||
|
||||
private async sendMediaRequestEmail(payload: NotificationPayload) {
|
||||
const settings = getSettings().main;
|
||||
// This is getting main settings for the whole app
|
||||
const applicationUrl = getSettings().main.applicationUrl;
|
||||
try {
|
||||
const userRepository = getRepository(User);
|
||||
const users = await userRepository.find();
|
||||
@@ -76,7 +98,10 @@ class EmailAgent implements NotificationAgent {
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
requestedBy: payload.notifyUser.username,
|
||||
actionUrl: settings.applicationUrl,
|
||||
actionUrl: applicationUrl
|
||||
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
|
||||
: undefined,
|
||||
applicationUrl,
|
||||
requestType: 'New Request',
|
||||
},
|
||||
});
|
||||
@@ -91,12 +116,59 @@ class EmailAgent implements NotificationAgent {
|
||||
}
|
||||
}
|
||||
|
||||
private async sendMediaFailedEmail(payload: NotificationPayload) {
|
||||
// This is getting main settings for the whole app
|
||||
const applicationUrl = getSettings().main.applicationUrl;
|
||||
try {
|
||||
const userRepository = getRepository(User);
|
||||
const users = await userRepository.find();
|
||||
|
||||
// Send to all users with the manage requests permission (or admins)
|
||||
users
|
||||
.filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS))
|
||||
.forEach((user) => {
|
||||
const email = this.getNewEmail();
|
||||
|
||||
email.send({
|
||||
template: path.join(
|
||||
__dirname,
|
||||
'../../../templates/email/media-request'
|
||||
),
|
||||
message: {
|
||||
to: user.email,
|
||||
},
|
||||
locals: {
|
||||
body:
|
||||
"A user's new request has failed to add to Sonarr or Radarr",
|
||||
mediaName: payload.subject,
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
requestedBy: payload.notifyUser.username,
|
||||
actionUrl: applicationUrl
|
||||
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
|
||||
: undefined,
|
||||
applicationUrl,
|
||||
requestType: 'Failed Request',
|
||||
},
|
||||
});
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Mail notification failed to send', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendMediaApprovedEmail(payload: NotificationPayload) {
|
||||
const settings = getSettings().main;
|
||||
// This is getting main settings for the whole app
|
||||
const applicationUrl = getSettings().main.applicationUrl;
|
||||
try {
|
||||
const email = this.getNewEmail();
|
||||
|
||||
email.send({
|
||||
await email.send({
|
||||
template: path.join(
|
||||
__dirname,
|
||||
'../../../templates/email/media-request'
|
||||
@@ -110,7 +182,10 @@ class EmailAgent implements NotificationAgent {
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
requestedBy: payload.notifyUser.username,
|
||||
actionUrl: settings.applicationUrl,
|
||||
actionUrl: applicationUrl
|
||||
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
|
||||
: undefined,
|
||||
applicationUrl,
|
||||
requestType: 'Request Approved',
|
||||
},
|
||||
});
|
||||
@@ -125,11 +200,12 @@ class EmailAgent implements NotificationAgent {
|
||||
}
|
||||
|
||||
private async sendMediaAvailableEmail(payload: NotificationPayload) {
|
||||
const settings = getSettings().main;
|
||||
// This is getting main settings for the whole app
|
||||
const applicationUrl = getSettings().main.applicationUrl;
|
||||
try {
|
||||
const email = this.getNewEmail();
|
||||
|
||||
email.send({
|
||||
await email.send({
|
||||
template: path.join(
|
||||
__dirname,
|
||||
'../../../templates/email/media-request'
|
||||
@@ -143,7 +219,10 @@ class EmailAgent implements NotificationAgent {
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
requestedBy: payload.notifyUser.username,
|
||||
actionUrl: settings.applicationUrl,
|
||||
actionUrl: applicationUrl
|
||||
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
|
||||
: undefined,
|
||||
applicationUrl,
|
||||
requestType: 'Now Available',
|
||||
},
|
||||
});
|
||||
@@ -157,6 +236,32 @@ class EmailAgent implements NotificationAgent {
|
||||
}
|
||||
}
|
||||
|
||||
private async sendTestEmail(payload: NotificationPayload) {
|
||||
// This is getting main settings for the whole app
|
||||
const applicationUrl = getSettings().main.applicationUrl;
|
||||
try {
|
||||
const email = this.getNewEmail();
|
||||
|
||||
await email.send({
|
||||
template: path.join(__dirname, '../../../templates/email/test-email'),
|
||||
message: {
|
||||
to: payload.notifyUser.email,
|
||||
},
|
||||
locals: {
|
||||
body: payload.message,
|
||||
applicationUrl,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Mail notification failed to send', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async send(
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
@@ -173,6 +278,12 @@ class EmailAgent implements NotificationAgent {
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
this.sendMediaAvailableEmail(payload);
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
this.sendMediaFailedEmail(payload);
|
||||
break;
|
||||
case Notification.TEST_NOTIFICATION:
|
||||
this.sendTestEmail(payload);
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
227
server/lib/notifications/agents/slack.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentSlack } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
|
||||
interface EmbedField {
|
||||
type: 'plain_text' | 'mrkdwn';
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface TextItem {
|
||||
type: 'plain_text' | 'mrkdwn';
|
||||
text: string;
|
||||
emoji?: boolean;
|
||||
}
|
||||
|
||||
interface Element {
|
||||
type: 'button';
|
||||
text?: TextItem;
|
||||
value: string;
|
||||
url: string;
|
||||
action_id: 'button-action';
|
||||
}
|
||||
|
||||
interface EmbedBlock {
|
||||
type: 'header' | 'actions' | 'section' | 'context';
|
||||
block_id?: 'section789';
|
||||
text?: TextItem;
|
||||
fields?: EmbedField[];
|
||||
accessory?: {
|
||||
type: 'image';
|
||||
image_url: string;
|
||||
alt_text: string;
|
||||
};
|
||||
elements?: Element[];
|
||||
}
|
||||
|
||||
interface SlackBlockEmbed {
|
||||
blocks: EmbedBlock[];
|
||||
}
|
||||
|
||||
class SlackAgent
|
||||
extends BaseAgent<NotificationAgentSlack>
|
||||
implements NotificationAgent {
|
||||
protected getSettings(): NotificationAgentSlack {
|
||||
if (this.settings) {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
|
||||
return settings.notifications.agents.slack;
|
||||
}
|
||||
|
||||
public buildEmbed(
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): SlackBlockEmbed {
|
||||
const settings = getSettings();
|
||||
let header = 'Overseerr';
|
||||
let actionUrl: string | undefined;
|
||||
|
||||
const fields: EmbedField[] = [];
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
header = 'New Request';
|
||||
fields.push(
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: `*Requested By*\n${payload.notifyUser.username ?? ''}`,
|
||||
},
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nPending Approval',
|
||||
}
|
||||
);
|
||||
if (settings.main.applicationUrl) {
|
||||
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
|
||||
}
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
header = 'Request Approved';
|
||||
fields.push(
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: `*Requested By*\n${payload.notifyUser.username ?? ''}`,
|
||||
},
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nProcessing Request',
|
||||
}
|
||||
);
|
||||
if (settings.main.applicationUrl) {
|
||||
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
|
||||
}
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
header = 'Now available!';
|
||||
fields.push(
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: `*Requested By*\n${payload.notifyUser.username ?? ''}`,
|
||||
},
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nAvailable',
|
||||
}
|
||||
);
|
||||
|
||||
if (settings.main.applicationUrl) {
|
||||
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const blocks: EmbedBlock[] = [
|
||||
{
|
||||
type: 'header',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: header,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: `*${payload.subject}*`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (payload.message) {
|
||||
blocks.push({
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: payload.message,
|
||||
},
|
||||
accessory: payload.image
|
||||
? {
|
||||
type: 'image',
|
||||
image_url: payload.image,
|
||||
alt_text: payload.subject,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (fields.length > 0) {
|
||||
blocks.push({
|
||||
type: 'section',
|
||||
fields: [
|
||||
...fields,
|
||||
...(payload.extra ?? []).map(
|
||||
(extra): EmbedField => ({
|
||||
type: 'mrkdwn',
|
||||
text: `*${extra.name}*\n${extra.value}`,
|
||||
})
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (actionUrl) {
|
||||
blocks.push({
|
||||
type: 'actions',
|
||||
elements: [
|
||||
{
|
||||
action_id: 'button-action',
|
||||
type: 'button',
|
||||
url: actionUrl,
|
||||
value: 'open_overseerr',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: 'Open Overseerr',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
blocks,
|
||||
};
|
||||
}
|
||||
|
||||
public shouldSend(type: Notification): boolean {
|
||||
if (
|
||||
this.getSettings().enabled &&
|
||||
this.getSettings().options.webhookUrl &&
|
||||
hasNotificationType(type, this.getSettings().types)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async send(
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
logger.debug('Sending slack notification', { label: 'Notifications' });
|
||||
try {
|
||||
const webhookUrl = this.getSettings().options.webhookUrl;
|
||||
|
||||
if (!webhookUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await axios.post(webhookUrl, this.buildEmbed(type, payload));
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Error sending Slack notification', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SlackAgent;
|
||||
127
server/lib/notifications/agents/telegram.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentTelegram } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
|
||||
interface TelegramPayload {
|
||||
text: string;
|
||||
parse_mode: string;
|
||||
chat_id: string;
|
||||
}
|
||||
|
||||
class TelegramAgent
|
||||
extends BaseAgent<NotificationAgentTelegram>
|
||||
implements NotificationAgent {
|
||||
private baseUrl = 'https://api.telegram.org/';
|
||||
|
||||
protected getSettings(): NotificationAgentTelegram {
|
||||
if (this.settings) {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
|
||||
return settings.notifications.agents.telegram;
|
||||
}
|
||||
|
||||
public shouldSend(type: Notification): boolean {
|
||||
if (
|
||||
this.getSettings().enabled &&
|
||||
this.getSettings().options.botAPI &&
|
||||
this.getSettings().options.chatId &&
|
||||
hasNotificationType(type, this.getSettings().types)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private escapeText(text: string | undefined): string {
|
||||
return text ? text.replace(/[_*[\]()~>#+=|{}.!-]/gi, (x) => '\\' + x) : '';
|
||||
}
|
||||
|
||||
private buildMessage(
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): string {
|
||||
const settings = getSettings();
|
||||
let message = '';
|
||||
|
||||
const title = this.escapeText(payload.subject);
|
||||
const plot = this.escapeText(payload.message);
|
||||
const user = this.escapeText(payload.notifyUser.username);
|
||||
|
||||
/* eslint-disable no-useless-escape */
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
message += `\*New Request\*\n`;
|
||||
message += `${title}\n\n`;
|
||||
message += `${plot}\n\n`;
|
||||
message += `\*Requested By\*\n${user}\n\n`;
|
||||
message += `\*Status\*\nPending Approval\n`;
|
||||
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
message += `\*Request Approved\*\n`;
|
||||
message += `${title}\n\n`;
|
||||
message += `${plot}\n\n`;
|
||||
message += `\*Requested By\*\n${user}\n\n`;
|
||||
message += `\*Status\*\nProcessing Request\n`;
|
||||
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
message += `\*Now available\\!\*\n`;
|
||||
message += `${title}\n\n`;
|
||||
message += `${plot}\n\n`;
|
||||
message += `\*Requested By\*\n${user}\n\n`;
|
||||
message += `\*Status\*\nAvailable\n`;
|
||||
|
||||
break;
|
||||
case Notification.TEST_NOTIFICATION:
|
||||
message += `\*Test Notification\*\n`;
|
||||
message += `${title}\n\n`;
|
||||
message += `${plot}\n\n`;
|
||||
message += `\*Requested By\*\n${user}\n`;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (settings.main.applicationUrl && payload.media) {
|
||||
const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
|
||||
message += `\[Open in Overseerr\]\(${actionUrl}\)`;
|
||||
}
|
||||
/* eslint-enable */
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
public async send(
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
logger.debug('Sending telegram notification', { label: 'Notifications' });
|
||||
try {
|
||||
const endpoint = `${this.baseUrl}bot${
|
||||
this.getSettings().options.botAPI
|
||||
}/sendMessage`;
|
||||
|
||||
await axios.post(endpoint, {
|
||||
text: this.buildMessage(type, payload),
|
||||
parse_mode: 'MarkdownV2',
|
||||
chat_id: `${this.getSettings().options.chatId}`,
|
||||
} as TelegramPayload);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Error sending Telegram notification', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TelegramAgent;
|
||||
@@ -5,8 +5,31 @@ export enum Notification {
|
||||
MEDIA_PENDING = 2,
|
||||
MEDIA_APPROVED = 4,
|
||||
MEDIA_AVAILABLE = 8,
|
||||
MEDIA_FAILED = 16,
|
||||
TEST_NOTIFICATION = 32,
|
||||
}
|
||||
|
||||
export const hasNotificationType = (
|
||||
types: Notification | Notification[],
|
||||
value: number
|
||||
): boolean => {
|
||||
let total = 0;
|
||||
|
||||
// If we are not checking any notifications, bail out and return true
|
||||
if (types === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Array.isArray(types)) {
|
||||
// Combine all notification values into one
|
||||
total = types.reduce((a, v) => a + v, 0);
|
||||
} else {
|
||||
total = types;
|
||||
}
|
||||
|
||||
return !!(value & total);
|
||||
};
|
||||
|
||||
class NotificationManager {
|
||||
private activeAgents: NotificationAgent[] = [];
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ export enum Permission {
|
||||
REQUEST = 32,
|
||||
VOTE = 64,
|
||||
AUTO_APPROVE = 128,
|
||||
AUTO_APPROVE_MOVIE = 256,
|
||||
AUTO_APPROVE_TV = 512,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { merge } from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Permission } from './permissions';
|
||||
|
||||
export interface Library {
|
||||
id: string;
|
||||
@@ -47,24 +48,31 @@ export interface SonarrSettings extends DVRSettings {
|
||||
export interface MainSettings {
|
||||
apiKey: string;
|
||||
applicationUrl: string;
|
||||
defaultPermissions: number;
|
||||
}
|
||||
|
||||
interface PublicSettings {
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
interface NotificationAgent {
|
||||
export interface NotificationAgentConfig {
|
||||
enabled: boolean;
|
||||
types: number;
|
||||
options: Record<string, unknown>;
|
||||
}
|
||||
interface NotificationAgentDiscord extends NotificationAgent {
|
||||
export interface NotificationAgentDiscord extends NotificationAgentConfig {
|
||||
options: {
|
||||
webhookUrl: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface NotificationAgentEmail extends NotificationAgent {
|
||||
export interface NotificationAgentSlack extends NotificationAgentConfig {
|
||||
options: {
|
||||
webhookUrl: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NotificationAgentEmail extends NotificationAgentConfig {
|
||||
options: {
|
||||
emailFrom: string;
|
||||
smtpHost: string;
|
||||
@@ -72,12 +80,23 @@ interface NotificationAgentEmail extends NotificationAgent {
|
||||
secure: boolean;
|
||||
authUser?: string;
|
||||
authPass?: string;
|
||||
allowSelfSigned: boolean;
|
||||
senderName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NotificationAgentTelegram extends NotificationAgentConfig {
|
||||
options: {
|
||||
botAPI: string;
|
||||
chatId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface NotificationAgents {
|
||||
email: NotificationAgentEmail;
|
||||
discord: NotificationAgentDiscord;
|
||||
slack: NotificationAgentSlack;
|
||||
telegram: NotificationAgentTelegram;
|
||||
}
|
||||
|
||||
interface NotificationSettings {
|
||||
@@ -105,6 +124,7 @@ class Settings {
|
||||
main: {
|
||||
apiKey: '',
|
||||
applicationUrl: '',
|
||||
defaultPermissions: Permission.REQUEST,
|
||||
},
|
||||
plex: {
|
||||
name: '',
|
||||
@@ -126,8 +146,10 @@ class Settings {
|
||||
options: {
|
||||
emailFrom: '',
|
||||
smtpHost: '127.0.0.1',
|
||||
smtpPort: 465,
|
||||
smtpPort: 587,
|
||||
secure: false,
|
||||
allowSelfSigned: false,
|
||||
senderName: 'Overseerr',
|
||||
},
|
||||
},
|
||||
discord: {
|
||||
@@ -137,6 +159,21 @@ class Settings {
|
||||
webhookUrl: '',
|
||||
},
|
||||
},
|
||||
slack: {
|
||||
enabled: false,
|
||||
types: 0,
|
||||
options: {
|
||||
webhookUrl: '',
|
||||
},
|
||||
},
|
||||
telegram: {
|
||||
enabled: false,
|
||||
types: 0,
|
||||
options: {
|
||||
botAPI: '',
|
||||
chatId: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import * as winston from 'winston';
|
||||
import 'winston-daily-rotate-file';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// Migrate away from old 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);
|
||||
|
||||
if (!file.isSymbolicLink()) {
|
||||
fs.unlinkSync(OLD_LOG_FILE);
|
||||
}
|
||||
}
|
||||
|
||||
const hformat = winston.format.printf(
|
||||
({ level, label, message, timestamp, ...metadata }) => {
|
||||
@@ -16,15 +28,27 @@ const hformat = winston.format.printf(
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'debug',
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.splat(),
|
||||
winston.format.timestamp(),
|
||||
hformat
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.Console(),
|
||||
new winston.transports.File({
|
||||
filename: path.join(__dirname, '../config/logs/overseerr.log'),
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.splat(),
|
||||
winston.format.timestamp(),
|
||||
hformat
|
||||
),
|
||||
}),
|
||||
new winston.transports.DailyRotateFile({
|
||||
filename: path.join(__dirname, '../config/logs/overseerr-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
zippedArchive: true,
|
||||
maxSize: '20m',
|
||||
maxFiles: '7d',
|
||||
createSymlink: true,
|
||||
symlinkName: 'overseerr.log',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
52
server/migration/1608477467935-AddLastSeasonChangeMedia.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddLastSeasonChangeMedia1608477467935
|
||||
implements MigrationInterface {
|
||||
name = 'AddLastSeasonChangeMedia1608477467935';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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), CONSTRAINT "UQ_7157aad07c73f6a6ae3bbd5ef5e" UNIQUE ("tmdbId"), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "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") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
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')), CONSTRAINT "UQ_7157aad07c73f6a6ae3bbd5ef5e" UNIQUE ("tmdbId"), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt" FROM "temporary_media"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ForceDropImdbUniqueConstraint1608477467935
|
||||
implements MigrationInterface {
|
||||
name = 'ForceDropImdbUniqueConstraint1608477467936';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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), CONSTRAINT "UQ_7157aad07c73f6a6ae3bbd5ef5e" UNIQUE ("tmdbId"), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "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") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
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')), CONSTRAINT "UQ_7157aad07c73f6a6ae3bbd5ef5e" UNIQUE ("tmdbId"), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt" FROM "temporary_media"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class RemoveTmdbIdUniqueConstraint1609236552057
|
||||
implements MigrationInterface {
|
||||
name = 'RemoveTmdbIdUniqueConstraint1609236552057';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "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") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
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), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"), CONSTRAINT "UQ_7157aad07c73f6a6ae3bbd5ef5e" UNIQUE ("tmdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "temporary_media"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||
);
|
||||
}
|
||||
}
|
||||
32
server/models/Collection.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { TmdbCollection } from '../api/themoviedb';
|
||||
import { MediaType } from '../constants/media';
|
||||
import Media from '../entity/Media';
|
||||
import { mapMovieResult, MovieResult } from './Search';
|
||||
|
||||
export interface Collection {
|
||||
id: number;
|
||||
name: string;
|
||||
overview?: string;
|
||||
posterPath?: string;
|
||||
backdropPath?: string;
|
||||
parts: MovieResult[];
|
||||
}
|
||||
|
||||
export const mapCollection = (
|
||||
collection: TmdbCollection,
|
||||
media: Media[]
|
||||
): Collection => ({
|
||||
id: collection.id,
|
||||
name: collection.name,
|
||||
overview: collection.overview,
|
||||
posterPath: collection.poster_path,
|
||||
backdropPath: collection.backdrop_path,
|
||||
parts: collection.parts.map((part) =>
|
||||
mapMovieResult(
|
||||
part,
|
||||
media?.find(
|
||||
(req) => req.tmdbId === part.id && req.mediaType === MediaType.MOVIE
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
@@ -8,9 +8,26 @@ import {
|
||||
mapCrew,
|
||||
ExternalIds,
|
||||
mapExternalIds,
|
||||
mapVideos,
|
||||
} from './common';
|
||||
import Media from '../entity/Media';
|
||||
|
||||
export interface Video {
|
||||
url?: string;
|
||||
site: 'YouTube';
|
||||
key: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type:
|
||||
| 'Clip'
|
||||
| 'Teaser'
|
||||
| 'Trailer'
|
||||
| 'Featurette'
|
||||
| 'Opening Credits'
|
||||
| 'Behind the Scenes'
|
||||
| 'Bloopers';
|
||||
}
|
||||
|
||||
export interface MovieDetails {
|
||||
id: number;
|
||||
imdbId?: string;
|
||||
@@ -23,6 +40,7 @@ export interface MovieDetails {
|
||||
originalTitle: string;
|
||||
overview?: string;
|
||||
popularity: number;
|
||||
relatedVideos?: Video[];
|
||||
posterPath?: string;
|
||||
productionCompanies: ProductionCompany[];
|
||||
productionCountries: {
|
||||
@@ -46,6 +64,12 @@ export interface MovieDetails {
|
||||
cast: Cast[];
|
||||
crew: Crew[];
|
||||
};
|
||||
collection?: {
|
||||
id: number;
|
||||
name: string;
|
||||
posterPath?: string;
|
||||
backdropPath?: string;
|
||||
};
|
||||
mediaInfo?: Media;
|
||||
externalIds: ExternalIds;
|
||||
}
|
||||
@@ -58,6 +82,7 @@ export const mapMovieDetails = (
|
||||
adult: movie.adult,
|
||||
budget: movie.budget,
|
||||
genres: movie.genres,
|
||||
relatedVideos: mapVideos(movie.videos),
|
||||
originalLanguage: movie.original_language,
|
||||
originalTitle: movie.original_title,
|
||||
popularity: movie.popularity,
|
||||
@@ -87,6 +112,14 @@ export const mapMovieDetails = (
|
||||
cast: movie.credits.cast.map(mapCast),
|
||||
crew: movie.credits.crew.map(mapCrew),
|
||||
},
|
||||
collection: movie.belongs_to_collection
|
||||
? {
|
||||
id: movie.belongs_to_collection.id,
|
||||
name: movie.belongs_to_collection.name,
|
||||
posterPath: movie.belongs_to_collection.poster_path,
|
||||
backdropPath: movie.belongs_to_collection.backdrop_path,
|
||||
}
|
||||
: undefined,
|
||||
externalIds: mapExternalIds(movie.external_ids),
|
||||
mediaInfo: media,
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
TmdbPersonResult,
|
||||
TmdbTvResult,
|
||||
} from '../api/themoviedb';
|
||||
import { MediaType as MainMediaType } from '../constants/media';
|
||||
import Media from '../entity/Media';
|
||||
|
||||
export type MediaType = 'tv' | 'movie' | 'person';
|
||||
@@ -78,7 +79,7 @@ export const mapTvResult = (
|
||||
media?: Media
|
||||
): TvResult => ({
|
||||
id: tvResult.id,
|
||||
firstAirDate: tvResult.first_air_Date,
|
||||
firstAirDate: tvResult.first_air_date,
|
||||
genreIds: tvResult.genre_ids,
|
||||
// Some results from tmdb dont return the mediaType so we force it here!
|
||||
mediaType: tvResult.media_type || 'tv',
|
||||
@@ -122,12 +123,18 @@ export const mapSearchResults = (
|
||||
case 'movie':
|
||||
return mapMovieResult(
|
||||
result,
|
||||
media?.find((req) => req.tmdbId === result.id)
|
||||
media?.find(
|
||||
(req) =>
|
||||
req.tmdbId === result.id && req.mediaType === MainMediaType.MOVIE
|
||||
)
|
||||
);
|
||||
case 'tv':
|
||||
return mapTvResult(
|
||||
result,
|
||||
media?.find((req) => req.tmdbId === result.id)
|
||||
media?.find(
|
||||
(req) =>
|
||||
req.tmdbId === result.id && req.mediaType === MainMediaType.TV
|
||||
)
|
||||
);
|
||||
default:
|
||||
return mapPersonResult(result);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ExternalIds,
|
||||
mapExternalIds,
|
||||
Keyword,
|
||||
mapVideos,
|
||||
} from './common';
|
||||
import {
|
||||
TmdbTvEpisodeResult,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
TmdbSeasonWithEpisodes,
|
||||
} from '../api/themoviedb';
|
||||
import type Media from '../entity/Media';
|
||||
import { Video } from './Movie';
|
||||
|
||||
interface Episode {
|
||||
id: number;
|
||||
@@ -67,6 +69,7 @@ export interface TvDetails {
|
||||
genres: Genre[];
|
||||
homepage: string;
|
||||
inProduction: boolean;
|
||||
relatedVideos?: Video[];
|
||||
languages: string[];
|
||||
lastAirDate: string;
|
||||
lastEpisodeToAir?: Episode;
|
||||
@@ -145,6 +148,7 @@ export const mapTvDetails = (
|
||||
id: genre.id,
|
||||
name: genre.name,
|
||||
})),
|
||||
relatedVideos: mapVideos(show.videos),
|
||||
homepage: show.homepage,
|
||||
id: show.id,
|
||||
inProduction: show.in_production,
|
||||
|
||||
@@ -2,8 +2,12 @@ import {
|
||||
TmdbCreditCast,
|
||||
TmdbCreditCrew,
|
||||
TmdbExternalIds,
|
||||
TmdbVideo,
|
||||
TmdbVideoResult,
|
||||
} from '../api/themoviedb';
|
||||
|
||||
import { Video } from '../models/Movie';
|
||||
|
||||
export interface ProductionCompany {
|
||||
id: number;
|
||||
logoPath?: string;
|
||||
@@ -84,3 +88,18 @@ export const mapExternalIds = (eids: TmdbExternalIds): ExternalIds => ({
|
||||
tvrageId: eids.tvrage_id,
|
||||
twitterId: eids.twitter_id,
|
||||
});
|
||||
|
||||
export const mapVideos = (videoResult: TmdbVideoResult): Video[] =>
|
||||
videoResult?.results.map(({ key, name, size, type, site }: TmdbVideo) => ({
|
||||
site,
|
||||
key,
|
||||
name,
|
||||
size,
|
||||
type,
|
||||
url: siteUrlCreator(site, key),
|
||||
}));
|
||||
|
||||
const siteUrlCreator = (site: Video['site'], key: string): string =>
|
||||
({
|
||||
YouTube: `https://www.youtube.com/watch?v=${key}/`,
|
||||
}[site]);
|
||||
|
||||
@@ -5,6 +5,7 @@ import PlexTvAPI from '../api/plextv';
|
||||
import { isAuthenticated } from '../middleware/auth';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import logger from '../logger';
|
||||
import { getSettings } from '../lib/settings';
|
||||
|
||||
const authRoutes = Router();
|
||||
|
||||
@@ -25,6 +26,7 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => {
|
||||
});
|
||||
|
||||
authRoutes.post('/login', async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
const body = req.body as { authToken?: string };
|
||||
|
||||
@@ -69,44 +71,48 @@ authRoutes.post('/login', async (req, res, next) => {
|
||||
await userRepository.save(user);
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
user = new User({
|
||||
email: account.email,
|
||||
username: account.username,
|
||||
plexId: account.id,
|
||||
plexToken: account.authToken,
|
||||
permissions: Permission.REQUEST,
|
||||
avatar: account.thumb,
|
||||
});
|
||||
await userRepository.save(user);
|
||||
} else {
|
||||
logger.info(
|
||||
'Failed login attempt from user without access to plex server',
|
||||
{
|
||||
label: 'Auth',
|
||||
account: {
|
||||
...account,
|
||||
authentication_token: '__REDACTED__',
|
||||
authToken: '__REDACTED__',
|
||||
},
|
||||
}
|
||||
);
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You do not have access to this Plex server',
|
||||
// Double check that we didn't create the first admin user before running this
|
||||
if (!user) {
|
||||
// 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)) {
|
||||
user = new User({
|
||||
email: account.email,
|
||||
username: account.username,
|
||||
plexId: account.id,
|
||||
plexToken: account.authToken,
|
||||
permissions: settings.main.defaultPermissions,
|
||||
avatar: account.thumb,
|
||||
});
|
||||
await userRepository.save(user);
|
||||
} else {
|
||||
logger.info(
|
||||
'Failed login attempt from user without access to plex server',
|
||||
{
|
||||
label: 'Auth',
|
||||
account: {
|
||||
...account,
|
||||
authentication_token: '__REDACTED__',
|
||||
authToken: '__REDACTED__',
|
||||
},
|
||||
}
|
||||
);
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You do not have access to this Plex server',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set logged in session
|
||||
if (req.session && user) {
|
||||
if (req.session) {
|
||||
req.session.userId = user.id;
|
||||
}
|
||||
|
||||
|
||||
27
server/routes/collection.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Router } from 'express';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import Media from '../entity/Media';
|
||||
import { mapCollection } from '../models/Collection';
|
||||
|
||||
const collectionRoutes = Router();
|
||||
|
||||
collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
try {
|
||||
const collection = await tmdb.getCollection({
|
||||
collectionId: Number(req.params.id),
|
||||
language: req.query.language as string,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
collection.parts.map((part) => part.id)
|
||||
);
|
||||
|
||||
return res.status(200).json(mapCollection(collection, media));
|
||||
} catch (e) {
|
||||
return next({ status: 404, message: 'Collection does not exist' });
|
||||
}
|
||||
});
|
||||
|
||||
export default collectionRoutes;
|
||||
@@ -26,7 +26,9 @@ discoverRoutes.get('/movies', async (req, res) => {
|
||||
results: data.results.map((result) =>
|
||||
mapMovieResult(
|
||||
result,
|
||||
media.find((req) => req.tmdbId === result.id)
|
||||
media.find(
|
||||
(req) => req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
@@ -12,10 +12,20 @@ import movieRoutes from './movie';
|
||||
import tvRoutes from './tv';
|
||||
import mediaRoutes from './media';
|
||||
import personRoutes from './person';
|
||||
import collectionRoutes from './collection';
|
||||
import { getAppVersion, getCommitTag } from '../utils/appVersion';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(checkUser);
|
||||
|
||||
router.get('/status', (req, res) => {
|
||||
return res.status(200).json({
|
||||
version: getAppVersion(),
|
||||
commitTag: getCommitTag(),
|
||||
});
|
||||
});
|
||||
|
||||
router.use('/user', isAuthenticated(Permission.MANAGE_USERS), user);
|
||||
router.get('/settings/public', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
@@ -34,6 +44,7 @@ router.use('/movie', isAuthenticated(), movieRoutes);
|
||||
router.use('/tv', isAuthenticated(), tvRoutes);
|
||||
router.use('/media', isAuthenticated(), mediaRoutes);
|
||||
router.use('/person', isAuthenticated(), personRoutes);
|
||||
router.use('/collection', isAuthenticated(), collectionRoutes);
|
||||
router.use('/auth', authRoutes);
|
||||
|
||||
router.get('/', (_req, res) => {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { mapMovieDetails } from '../models/Movie';
|
||||
import { mapMovieResult } from '../models/Search';
|
||||
import Media from '../entity/Media';
|
||||
import RottenTomatoes from '../api/rottentomatoes';
|
||||
import logger from '../logger';
|
||||
import { MediaType } from '../constants/media';
|
||||
|
||||
const movieRoutes = Router();
|
||||
|
||||
@@ -11,15 +13,19 @@ movieRoutes.get('/:id', async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
try {
|
||||
const movie = await tmdb.getMovie({
|
||||
const tmdbMovie = await tmdb.getMovie({
|
||||
movieId: Number(req.params.id),
|
||||
language: req.query.language as string,
|
||||
});
|
||||
|
||||
const media = await Media.getMedia(movie.id);
|
||||
const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE);
|
||||
|
||||
return res.status(200).json(mapMovieDetails(movie, media));
|
||||
return res.status(200).json(mapMovieDetails(tmdbMovie, media));
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong getting movie', {
|
||||
label: 'Movie',
|
||||
message: e.message,
|
||||
});
|
||||
return next({ status: 404, message: 'Movie does not exist' });
|
||||
}
|
||||
});
|
||||
@@ -44,7 +50,9 @@ movieRoutes.get('/:id/recommendations', async (req, res) => {
|
||||
results: results.results.map((result) =>
|
||||
mapMovieResult(
|
||||
result,
|
||||
media.find((req) => req.tmdbId === result.id)
|
||||
media.find(
|
||||
(req) => req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
@@ -70,7 +78,9 @@ movieRoutes.get('/:id/similar', async (req, res) => {
|
||||
results: results.results.map((result) =>
|
||||
mapMovieResult(
|
||||
result,
|
||||
media.find((req) => req.tmdbId === result.id)
|
||||
media.find(
|
||||
(req) => req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
@@ -45,13 +45,19 @@ personRoutes.get('/:id/combined_credits', async (req, res) => {
|
||||
cast: combinedCredits.cast.map((result) =>
|
||||
mapCastCredits(
|
||||
result,
|
||||
castMedia.find((med) => med.tmdbId === result.id)
|
||||
castMedia.find(
|
||||
(med) =>
|
||||
med.tmdbId === result.id && med.mediaType === result.media_type
|
||||
)
|
||||
)
|
||||
),
|
||||
crew: combinedCredits.crew.map((result) =>
|
||||
mapCrewCredits(
|
||||
result,
|
||||
crewMedia.find((med) => med.tmdbId === result.id)
|
||||
crewMedia.find(
|
||||
(med) =>
|
||||
med.tmdbId === result.id && med.mediaType === result.media_type
|
||||
)
|
||||
)
|
||||
),
|
||||
id: combinedCredits.id,
|
||||
|
||||
@@ -102,7 +102,7 @@ requestRoutes.post(
|
||||
: await tmdb.getTvShow({ tvId: req.body.mediaId });
|
||||
|
||||
let media = await mediaRepository.findOne({
|
||||
where: { tmdbId: req.body.mediaId },
|
||||
where: { tmdbId: req.body.mediaId, mediaType: req.body.mediaType },
|
||||
relations: ['requests'],
|
||||
});
|
||||
|
||||
@@ -127,12 +127,16 @@ requestRoutes.post(
|
||||
media,
|
||||
requestedBy: req.user,
|
||||
// If the user is an admin or has the "auto approve" permission, automatically approve the request
|
||||
status: req.user?.hasPermission(Permission.AUTO_APPROVE)
|
||||
? MediaRequestStatus.APPROVED
|
||||
: MediaRequestStatus.PENDING,
|
||||
modifiedBy: req.user?.hasPermission(Permission.AUTO_APPROVE)
|
||||
? req.user
|
||||
: undefined,
|
||||
status:
|
||||
req.user?.hasPermission(Permission.AUTO_APPROVE) ||
|
||||
req.user?.hasPermission(Permission.AUTO_APPROVE_MOVIE)
|
||||
? MediaRequestStatus.APPROVED
|
||||
: MediaRequestStatus.PENDING,
|
||||
modifiedBy:
|
||||
req.user?.hasPermission(Permission.AUTO_APPROVE) ||
|
||||
req.user?.hasPermission(Permission.AUTO_APPROVE_MOVIE)
|
||||
? req.user
|
||||
: undefined,
|
||||
});
|
||||
|
||||
await requestRepository.save(request);
|
||||
@@ -160,7 +164,7 @@ requestRoutes.post(
|
||||
|
||||
if (finalSeasons.length === 0) {
|
||||
return next({
|
||||
status: 500,
|
||||
status: 202,
|
||||
message: 'No seasons available to request',
|
||||
});
|
||||
}
|
||||
@@ -172,19 +176,25 @@ requestRoutes.post(
|
||||
} as Media,
|
||||
requestedBy: req.user,
|
||||
// If the user is an admin or has the "auto approve" permission, automatically approve the request
|
||||
status: req.user?.hasPermission(Permission.AUTO_APPROVE)
|
||||
? MediaRequestStatus.APPROVED
|
||||
: MediaRequestStatus.PENDING,
|
||||
modifiedBy: req.user?.hasPermission(Permission.AUTO_APPROVE)
|
||||
? req.user
|
||||
: undefined,
|
||||
status:
|
||||
req.user?.hasPermission(Permission.AUTO_APPROVE) ||
|
||||
req.user?.hasPermission(Permission.AUTO_APPROVE_TV)
|
||||
? MediaRequestStatus.APPROVED
|
||||
: MediaRequestStatus.PENDING,
|
||||
modifiedBy:
|
||||
req.user?.hasPermission(Permission.AUTO_APPROVE) ||
|
||||
req.user?.hasPermission(Permission.AUTO_APPROVE_TV)
|
||||
? req.user
|
||||
: undefined,
|
||||
seasons: finalSeasons.map(
|
||||
(sn) =>
|
||||
new SeasonRequest({
|
||||
seasonNumber: sn,
|
||||
status: req.user?.hasPermission(Permission.AUTO_APPROVE)
|
||||
? MediaRequestStatus.APPROVED
|
||||
: MediaRequestStatus.PENDING,
|
||||
status:
|
||||
req.user?.hasPermission(Permission.AUTO_APPROVE) ||
|
||||
req.user?.hasPermission(Permission.AUTO_APPROVE_TV)
|
||||
? MediaRequestStatus.APPROVED
|
||||
: MediaRequestStatus.PENDING,
|
||||
})
|
||||
),
|
||||
});
|
||||
@@ -244,6 +254,32 @@ requestRoutes.delete('/:requestId', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
requestRoutes.post<{
|
||||
requestId: string;
|
||||
}>(
|
||||
'/:requestId/retry',
|
||||
isAuthenticated(Permission.MANAGE_REQUESTS),
|
||||
async (req, res, next) => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
try {
|
||||
const request = await requestRepository.findOneOrFail({
|
||||
where: { id: Number(req.params.requestId) },
|
||||
relations: ['requestedBy', 'modifiedBy'],
|
||||
});
|
||||
|
||||
await request.updateParentStatus();
|
||||
await request.sendMedia();
|
||||
return res.status(200).json(request);
|
||||
} catch (e) {
|
||||
logger.error('Error processing request retry', {
|
||||
label: 'Media Request',
|
||||
message: e.message,
|
||||
});
|
||||
next({ status: 404, message: 'Request not found' });
|
||||
}
|
||||
}
|
||||
);
|
||||
requestRoutes.get<{
|
||||
requestId: string;
|
||||
status: 'pending' | 'approve' | 'decline';
|
||||
|
||||
@@ -16,11 +16,16 @@ import logger from '../logger';
|
||||
import { scheduledJobs } from '../job/schedule';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import { isAuthenticated } from '../middleware/auth';
|
||||
import { merge } from 'lodash';
|
||||
import { merge, omit } from 'lodash';
|
||||
import Media from '../entity/Media';
|
||||
import { MediaRequest } from '../entity/MediaRequest';
|
||||
import { getAppVersion } from '../utils/appVersion';
|
||||
import { SettingsAboutResponse } from '../interfaces/api/settingsInterfaces';
|
||||
import { Notification } from '../lib/notifications';
|
||||
import DiscordAgent from '../lib/notifications/agents/discord';
|
||||
import EmailAgent from '../lib/notifications/agents/email';
|
||||
import SlackAgent from '../lib/notifications/agents/slack';
|
||||
import TelegramAgent from '../lib/notifications/agents/telegram';
|
||||
|
||||
const settingsRoutes = Router();
|
||||
|
||||
@@ -29,9 +34,7 @@ const filteredMainSettings = (
|
||||
main: MainSettings
|
||||
): Partial<MainSettings> => {
|
||||
if (!user?.hasPermission(Permission.ADMIN)) {
|
||||
return {
|
||||
applicationUrl: main.applicationUrl,
|
||||
};
|
||||
return omit(main, 'apiKey');
|
||||
}
|
||||
|
||||
return main;
|
||||
@@ -448,6 +451,93 @@ settingsRoutes.post('/notifications/discord', (req, res) => {
|
||||
res.status(200).json(settings.notifications.agents.discord);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/notifications/discord/test', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
});
|
||||
}
|
||||
|
||||
const discordAgent = new DiscordAgent(req.body);
|
||||
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?',
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
settingsRoutes.get('/notifications/slack', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.slack);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/notifications/slack', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.slack = req.body;
|
||||
settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.slack);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/notifications/slack/test', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
});
|
||||
}
|
||||
|
||||
const slackAgent = new SlackAgent(req.body);
|
||||
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?',
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
settingsRoutes.get('/notifications/telegram', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.telegram);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/notifications/telegram', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.telegram = req.body;
|
||||
settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.telegram);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/notifications/telegram/test', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
});
|
||||
}
|
||||
|
||||
const telegramAgent = new TelegramAgent(req.body);
|
||||
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?',
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
settingsRoutes.get('/notifications/email', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
@@ -463,6 +553,25 @@ settingsRoutes.post('/notifications/email', (req, res) => {
|
||||
res.status(200).json(settings.notifications.agents.email);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/notifications/email/test', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
});
|
||||
}
|
||||
|
||||
const emailAgent = new EmailAgent(req.body);
|
||||
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?',
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
settingsRoutes.get('/about', async (req, res) => {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const mediaRequestRepository = getRepository(MediaRequest);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { mapTvResult } from '../models/Search';
|
||||
import Media from '../entity/Media';
|
||||
import RottenTomatoes from '../api/rottentomatoes';
|
||||
import logger from '../logger';
|
||||
import { MediaType } from '../constants/media';
|
||||
|
||||
const tvRoutes = Router();
|
||||
|
||||
@@ -16,7 +17,7 @@ tvRoutes.get('/:id', async (req, res, next) => {
|
||||
language: req.query.language as string,
|
||||
});
|
||||
|
||||
const media = await Media.getMedia(tv.id);
|
||||
const media = await Media.getMedia(tv.id, MediaType.TV);
|
||||
|
||||
return res.status(200).json(mapTvDetails(tv, media));
|
||||
} catch (e) {
|
||||
@@ -60,7 +61,9 @@ tvRoutes.get('/:id/recommendations', async (req, res) => {
|
||||
results: results.results.map((result) =>
|
||||
mapTvResult(
|
||||
result,
|
||||
media.find((req) => req.tmdbId === result.id)
|
||||
media.find(
|
||||
(req) => req.tmdbId === result.id && req.mediaType === MediaType.TV
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
@@ -86,7 +89,9 @@ tvRoutes.get('/:id/similar', async (req, res) => {
|
||||
results: results.results.map((result) =>
|
||||
mapTvResult(
|
||||
result,
|
||||
media.find((req) => req.tmdbId === result.id)
|
||||
media.find(
|
||||
(req) => req.tmdbId === result.id && req.mediaType === MediaType.TV
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
import { getRepository } from 'typeorm';
|
||||
import PlexTvAPI from '../api/plextv';
|
||||
import { MediaRequest } from '../entity/MediaRequest';
|
||||
import { User } from '../entity/User';
|
||||
import { hasPermission, Permission } from '../lib/permissions';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
|
||||
const router = Router();
|
||||
@@ -142,4 +144,51 @@ router.delete<{ id: string }>('/:id', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/import-from-plex', async (req, res, next) => {
|
||||
try {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
// taken from auth.ts
|
||||
const mainUser = await userRepository.findOneOrFail({
|
||||
select: ['id', 'plexToken'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
|
||||
|
||||
const plexUsersResponse = await mainPlexTv.getUsers();
|
||||
const createdUsers: User[] = [];
|
||||
for (const rawUser of plexUsersResponse.MediaContainer.User) {
|
||||
const account = rawUser.$;
|
||||
const user = await userRepository.findOne({
|
||||
where: { plexId: account.id },
|
||||
});
|
||||
if (user) {
|
||||
// Update the users avatar with their plex thumbnail (incase it changed)
|
||||
user.avatar = account.thumb;
|
||||
user.email = account.email;
|
||||
user.username = account.username;
|
||||
await userRepository.save(user);
|
||||
} else {
|
||||
// Check to make sure it's a real account
|
||||
if (account.email && account.username) {
|
||||
const newUser = new User({
|
||||
username: account.username,
|
||||
email: account.email,
|
||||
permissions: settings.main.defaultPermissions,
|
||||
plexId: parseInt(account.id),
|
||||
plexToken: '',
|
||||
avatar: account.thumb,
|
||||
});
|
||||
await userRepository.save(newUser);
|
||||
createdUsers.push(newUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
return res.status(201).json(User.filterMany(createdUsers));
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -29,6 +29,7 @@ export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
notifyUser: request.requestedBy,
|
||||
subject: movie.title,
|
||||
message: movie.overview,
|
||||
media: entity,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
});
|
||||
});
|
||||
@@ -79,6 +80,7 @@ export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
message: tv.overview,
|
||||
notifyUser: request.requestedBy,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||
media: entity,
|
||||
extra: [
|
||||
{
|
||||
name: 'Seasons',
|
||||
|
||||
@@ -47,7 +47,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
|
||||
padding-bottom: 25px;\
|
||||
text-align: center;\
|
||||
')
|
||||
a(href=actionUrl style='\
|
||||
a(href=applicationUrl style='\
|
||||
text-shadow: 0 1px 0 #ffffff;\
|
||||
font-weight: 700;\
|
||||
font-size: 16px;\
|
||||
@@ -92,7 +92,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
|
||||
margin-bottom: 20px;\
|
||||
color: #51545e;\
|
||||
')
|
||||
a(href=actionUrl style='color: #3869d4') Open Overseerr
|
||||
a(href=actionUrl style='color: #3869d4') Open Media in Overseerr
|
||||
tr
|
||||
td
|
||||
table.sm-w-full(align='center' style='\
|
||||
|
||||
96
server/templates/email/test-email/html.pug
Normal file
@@ -0,0 +1,96 @@
|
||||
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/css?family=Nunito+Sans:400,700&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.
|
||||
@media (max-width: 600px) {
|
||||
.sm-w-full {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
div(role='article' aria-roledescription='email' aria-label='' lang='en')
|
||||
table(style="\
|
||||
background-color: #f2f4f6;\
|
||||
font-family: 'Nunito Sans', -apple-system, 'Segoe UI', sans-serif;\
|
||||
width: 100%;\
|
||||
" width='100%' bgcolor='#f2f4f6' cellpadding='0' cellspacing='0' role='presentation')
|
||||
tr
|
||||
td(align='center')
|
||||
table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation')
|
||||
tr
|
||||
td(align='center' style='\
|
||||
font-size: 16px;\
|
||||
padding-top: 25px;\
|
||||
padding-bottom: 25px;\
|
||||
text-align: center;\
|
||||
')
|
||||
a(href=applicationUrl style='\
|
||||
text-shadow: 0 1px 0 #ffffff;\
|
||||
font-weight: 700;\
|
||||
font-size: 16px;\
|
||||
color: #a8aaaf;\
|
||||
text-decoration: none;\
|
||||
')
|
||||
| Overseerr
|
||||
tr
|
||||
td(style='width: 100%' width='100%')
|
||||
table.sm-w-full(align='center' style='\
|
||||
background-color: #ffffff;\
|
||||
margin-left: auto;\
|
||||
margin-right: auto;\
|
||||
width: 570px;\
|
||||
' width='570' bgcolor='#ffffff' cellpadding='0' cellspacing='0' role='presentation')
|
||||
tr
|
||||
td(style='padding: 45px')
|
||||
div(style='font-size: 16px')
|
||||
| #{body}
|
||||
p(style='\
|
||||
font-size: 13px;\
|
||||
line-height: 24px;\
|
||||
margin-top: 6px;\
|
||||
margin-bottom: 20px;\
|
||||
color: #51545e;\
|
||||
')
|
||||
a(href=applicationUrl style='color: #3869d4') Open Overseerr
|
||||
tr
|
||||
td
|
||||
table.sm-w-full(align='center' style='\
|
||||
margin-left: auto;\
|
||||
margin-right: auto;\
|
||||
text-align: center;\
|
||||
width: 570px;\
|
||||
' width='570' cellpadding='0' cellspacing='0' role='presentation')
|
||||
tr
|
||||
td(align='center' style='font-size: 16px; padding: 45px')
|
||||
p(style='\
|
||||
font-size: 13px;\
|
||||
line-height: 24px;\
|
||||
margin-top: 6px;\
|
||||
margin-bottom: 20px;\
|
||||
text-align: center;\
|
||||
color: #a8aaaf;\
|
||||
')
|
||||
| Overseerr.
|
||||
1
server/templates/email/test-email/subject.pug
Normal file
@@ -0,0 +1 @@
|
||||
= `Test Notification - Overseerr`
|
||||
@@ -1,3 +1,20 @@
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import logger from '../logger';
|
||||
|
||||
const COMMIT_TAG_PATH = path.join(__dirname, '../../committag.json');
|
||||
let commitTag = 'local';
|
||||
|
||||
if (existsSync(COMMIT_TAG_PATH)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
commitTag = require(COMMIT_TAG_PATH).commitTag;
|
||||
logger.info(`Commit Tag: ${commitTag}`);
|
||||
}
|
||||
|
||||
export const getCommitTag = (): string => {
|
||||
return commitTag;
|
||||
};
|
||||
|
||||
export const getAppVersion = (): string => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { version } = require('../../package.json');
|
||||
@@ -5,7 +22,7 @@ export const getAppVersion = (): string => {
|
||||
let finalVersion = version;
|
||||
|
||||
if (version === '0.1.0') {
|
||||
finalVersion = `develop-${process.env.COMMIT_TAG ?? 'local'}`;
|
||||
finalVersion = `develop-${getCommitTag()}`;
|
||||
}
|
||||
|
||||
return finalVersion;
|
||||
|
||||
6
src/assets/ellipsis.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="22" height="10" viewBox="0 0 22 10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="22" height="10" rx="2" fill="#718096"/>
|
||||
<circle cx="5" cy="5" r="2" fill="#E2E8F0"/>
|
||||
<circle cx="11" cy="5" r="2" fill="#E2E8F0"/>
|
||||
<circle cx="17" cy="5" r="2" fill="#E2E8F0"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 293 B |
1
src/assets/extlogos/discord_white.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="discord" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M297.216 243.2c0 15.616-11.52 28.416-26.112 28.416-14.336 0-26.112-12.8-26.112-28.416s11.52-28.416 26.112-28.416c14.592 0 26.112 12.8 26.112 28.416zm-119.552-28.416c-14.592 0-26.112 12.8-26.112 28.416s11.776 28.416 26.112 28.416c14.592 0 26.112-12.8 26.112-28.416.256-15.616-11.52-28.416-26.112-28.416zM448 52.736V512c-64.494-56.994-43.868-38.128-118.784-107.776l13.568 47.36H52.48C23.552 451.584 0 428.032 0 398.848V52.736C0 23.552 23.552 0 52.48 0h343.04C424.448 0 448 23.552 448 52.736zm-72.96 242.688c0-82.432-36.864-149.248-36.864-149.248-36.864-27.648-71.936-26.88-71.936-26.88l-3.584 4.096c43.52 13.312 63.744 32.512 63.744 32.512-60.811-33.329-132.244-33.335-191.232-7.424-9.472 4.352-15.104 7.424-15.104 7.424s21.248-20.224 67.328-33.536l-2.56-3.072s-35.072-.768-71.936 26.88c0 0-36.864 66.816-36.864 149.248 0 0 21.504 37.12 78.08 38.912 0 0 9.472-11.52 17.152-21.248-32.512-9.728-44.8-30.208-44.8-30.208 3.766 2.636 9.976 6.053 10.496 6.4 43.21 24.198 104.588 32.126 159.744 8.96 8.96-3.328 18.944-8.192 29.44-15.104 0 0-12.8 20.992-46.336 30.464 7.68 9.728 16.896 20.736 16.896 20.736 56.576-1.792 78.336-38.912 78.336-38.912z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
src/assets/extlogos/slack.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="slack" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M94.12 315.1c0 25.9-21.16 47.06-47.06 47.06S0 341 0 315.1c0-25.9 21.16-47.06 47.06-47.06h47.06v47.06zm23.72 0c0-25.9 21.16-47.06 47.06-47.06s47.06 21.16 47.06 47.06v117.84c0 25.9-21.16 47.06-47.06 47.06s-47.06-21.16-47.06-47.06V315.1zm47.06-188.98c-25.9 0-47.06-21.16-47.06-47.06S139 32 164.9 32s47.06 21.16 47.06 47.06v47.06H164.9zm0 23.72c25.9 0 47.06 21.16 47.06 47.06s-21.16 47.06-47.06 47.06H47.06C21.16 243.96 0 222.8 0 196.9s21.16-47.06 47.06-47.06H164.9zm188.98 47.06c0-25.9 21.16-47.06 47.06-47.06 25.9 0 47.06 21.16 47.06 47.06s-21.16 47.06-47.06 47.06h-47.06V196.9zm-23.72 0c0 25.9-21.16 47.06-47.06 47.06-25.9 0-47.06-21.16-47.06-47.06V79.06c0-25.9 21.16-47.06 47.06-47.06 25.9 0 47.06 21.16 47.06 47.06V196.9zM283.1 385.88c25.9 0 47.06 21.16 47.06 47.06 0 25.9-21.16 47.06-47.06 47.06-25.9 0-47.06-21.16-47.06-47.06v-47.06h47.06zm0-23.72c-25.9 0-47.06-21.16-47.06-47.06 0-25.9 21.16-47.06 47.06-47.06h117.84c25.9 0 47.06 21.16 47.06 47.06 0 25.9-21.16 47.06-47.06 47.06H283.1z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
3
src/assets/extlogos/telegram.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 24C0 37.2548 10.7452 48 24 48C37.2548 48 48 37.2548 48 24C48 10.7452 37.2548 0 24 0C10.7452 0 0 10.7452 0 24ZM19.6 35L20.0083 28.8823L20.008 28.882L31.1369 18.839C31.6253 18.4055 31.0303 18.1941 30.3819 18.5873L16.6473 27.2523L10.7147 25.4007C9.4335 25.0084 9.4243 24.128 11.0023 23.4951L34.1203 14.5809C35.1762 14.1015 36.1953 14.8345 35.7922 16.4505L31.8552 35.0031C31.5803 36.3215 30.7837 36.6368 29.68 36.0278L23.6827 31.5969L20.8 34.4C20.7909 34.4088 20.7819 34.4176 20.7729 34.4264C20.4505 34.7403 20.1837 35 19.6 35Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 679 B |
@@ -1,4 +1,4 @@
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M43.5 0H0L43.5 43.5V0Z" fill="#E53E3E"/>
|
||||
<path d="M43.5 0H0L43.5 43.5V0Z" fill="#667EEA"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M31 20.2C32.9096 20.2 34.7409 19.4414 36.0912 18.0912C37.4414 16.7409 38.2 14.9095 38.2 13C38.2 11.0904 37.4414 9.25908 36.0912 7.90882C34.7409 6.55856 32.9096 5.79999 31 5.79999C29.0904 5.79999 27.2591 6.55856 25.9088 7.90882C24.5586 9.25908 23.8 11.0904 23.8 13C23.8 14.9095 24.5586 16.7409 25.9088 18.0912C27.2591 19.4414 29.0904 20.2 31 20.2ZM31.9 9.39999C31.9 9.16129 31.8052 8.93237 31.6364 8.76359C31.4676 8.59481 31.2387 8.49999 31 8.49999C30.7613 8.49999 30.5324 8.59481 30.3636 8.76359C30.1948 8.93237 30.1 9.16129 30.1 9.39999V13C30.1 13.2387 30.1949 13.4675 30.3637 13.6363L32.9089 16.1824C32.9925 16.266 33.0918 16.3323 33.201 16.3776C33.3103 16.4228 33.4274 16.4461 33.5456 16.4461C33.6639 16.4461 33.781 16.4228 33.8903 16.3776C33.9995 16.3323 34.0988 16.266 34.1824 16.1824C34.266 16.0988 34.3323 15.9995 34.3776 15.8902C34.4229 15.781 34.4461 15.6639 34.4461 15.5456C34.4461 15.4274 34.4229 15.3103 34.3776 15.201C34.3323 15.0918 34.266 14.9925 34.1824 14.9089L31.9 12.6274V9.39999Z" fill="white"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
1
src/assets/xcircle.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
|
After Width: | Height: | Size: 267 B |
267
src/components/CollectionDetails/index.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import axios from 'axios';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useContext, 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 { LanguageContext } from '../../context/LanguageContext';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import Error from '../../pages/_error';
|
||||
import Badge from '../Common/Badge';
|
||||
import Button from '../Common/Button';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import Modal from '../Common/Modal';
|
||||
import Slider from '../Slider';
|
||||
import TitleCard from '../TitleCard';
|
||||
import Transition from '../Transition';
|
||||
|
||||
const messages = defineMessages({
|
||||
overviewunavailable: 'Overview unavailable',
|
||||
overview: 'Overview',
|
||||
movies: 'Movies',
|
||||
numberofmovies: 'Number of Movies: {count}',
|
||||
requesting: 'Requesting…',
|
||||
request: 'Request',
|
||||
requestcollection: 'Request Collection',
|
||||
requestswillbecreated:
|
||||
'The following titles will have requests created for them:',
|
||||
requestSuccess: '<strong>{title}</strong> successfully requested!',
|
||||
});
|
||||
|
||||
interface CollectionDetailsProps {
|
||||
collection?: Collection;
|
||||
}
|
||||
|
||||
const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
||||
collection,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const router = useRouter();
|
||||
const { addToast } = useToasts();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const [requestModal, setRequestModal] = useState(false);
|
||||
const [isRequesting, setRequesting] = useState(false);
|
||||
const { data, error, revalidate } = useSWR<Collection>(
|
||||
`/api/v1/collection/${router.query.collectionId}?language=${locale}`,
|
||||
{
|
||||
initialData: collection,
|
||||
revalidateOnMount: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Error statusCode={404} />;
|
||||
}
|
||||
|
||||
const requestableParts = data.parts.filter(
|
||||
(part) => !part.mediaInfo || part.mediaInfo.status === MediaStatus.UNKNOWN
|
||||
);
|
||||
|
||||
const requestBundle = async () => {
|
||||
try {
|
||||
setRequesting(true);
|
||||
await Promise.all(
|
||||
requestableParts.map(async (part) => {
|
||||
await axios.post<MediaRequest>('/api/v1/request', {
|
||||
mediaId: part.id,
|
||||
mediaType: 'movie',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
addToast(
|
||||
<span>
|
||||
{intl.formatMessage(messages.requestSuccess, {
|
||||
title: data?.name,
|
||||
strong: function strong(msg) {
|
||||
return <strong>{msg}</strong>;
|
||||
},
|
||||
})}
|
||||
</span>,
|
||||
{ appearance: 'success', autoDismiss: true }
|
||||
);
|
||||
} catch (e) {
|
||||
addToast('Something went wrong requesting the collection.', {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
setRequestModal(false);
|
||||
revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover"
|
||||
style={{
|
||||
height: 493,
|
||||
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
|
||||
}}
|
||||
>
|
||||
<Head>
|
||||
<title>{data.name} - Overseerr</title>
|
||||
</Head>
|
||||
<Transition
|
||||
enter="opacity-0 transition duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="opacity-100 transition duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={requestModal}
|
||||
>
|
||||
<Modal
|
||||
onOk={() => requestBundle()}
|
||||
okText={
|
||||
isRequesting
|
||||
? intl.formatMessage(messages.requesting)
|
||||
: intl.formatMessage(messages.request)
|
||||
}
|
||||
okDisabled={isRequesting}
|
||||
okButtonType="primary"
|
||||
onCancel={() => setRequestModal(false)}
|
||||
title={intl.formatMessage(messages.requestcollection)}
|
||||
iconSvg={
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<p>{intl.formatMessage(messages.requestswillbecreated)}</p>
|
||||
<ul className="py-4 pl-8 list-disc">
|
||||
{data.parts
|
||||
.filter(
|
||||
(part) =>
|
||||
!part.mediaInfo ||
|
||||
part.mediaInfo?.status === MediaStatus.UNKNOWN
|
||||
)
|
||||
.map((part) => (
|
||||
<li key={`request-part-${part.id}`}>{part.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Modal>
|
||||
</Transition>
|
||||
<div className="flex flex-col items-center pt-4 md:flex-row md:items-end">
|
||||
<div className="flex-shrink-0 md:mr-4">
|
||||
<img
|
||||
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
|
||||
alt=""
|
||||
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-52"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col mt-4 text-center text-white md:mr-4 md:mt-0 md:text-left">
|
||||
<div className="mb-2">
|
||||
{data.parts.every(
|
||||
(part) => part.mediaInfo?.status === MediaStatus.AVAILABLE
|
||||
) && (
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(globalMessages.available)}
|
||||
</Badge>
|
||||
)}
|
||||
{!data.parts.every(
|
||||
(part) => part.mediaInfo?.status === MediaStatus.AVAILABLE
|
||||
) &&
|
||||
data.parts.some(
|
||||
(part) => part.mediaInfo?.status === MediaStatus.AVAILABLE
|
||||
) && (
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(globalMessages.partiallyavailable)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-4xl">{data.name}</h1>
|
||||
<span className="mt-1 text-xs md:text-base md:mt-0">
|
||||
{intl.formatMessage(messages.numberofmovies, {
|
||||
count: data.parts.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-end flex-1 mt-4 md:mt-0">
|
||||
{data.parts.some(
|
||||
(part) =>
|
||||
!part.mediaInfo || part.mediaInfo?.status === MediaStatus.UNKNOWN
|
||||
) && (
|
||||
<Button buttonType="primary" onClick={() => setRequestModal(true)}>
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
{intl.formatMessage(messages.requestcollection)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col pt-8 pb-4 text-white md:flex-row">
|
||||
<div className="flex-1 md:mr-8">
|
||||
<h2 className="text-xl md:text-2xl">
|
||||
{intl.formatMessage(messages.overview)}
|
||||
</h2>
|
||||
<p className="pt-2 text-sm md:text-base">
|
||||
{data.overview
|
||||
? data.overview
|
||||
: intl.formatMessage(messages.overviewunavailable)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="inline-flex items-center text-xl leading-7 text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>{intl.formatMessage(messages.movies)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="collection-movies"
|
||||
isLoading={false}
|
||||
isEmpty={data.parts.length === 0}
|
||||
items={data.parts.map((title) => (
|
||||
<TitleCard
|
||||
key={`collection-movie-${title.id}`}
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
title={title.title}
|
||||
userScore={title.voteAverage}
|
||||
year={title.releaseDate}
|
||||
mediaType={title.mediaType}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
<div className="pb-8" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionDetails;
|
||||
@@ -2,31 +2,66 @@ import React from 'react';
|
||||
|
||||
interface AlertProps {
|
||||
title: string;
|
||||
type?: 'warning';
|
||||
type?: 'warning' | 'info';
|
||||
}
|
||||
|
||||
const Alert: React.FC<AlertProps> = ({ title, children }) => {
|
||||
return (
|
||||
<div className="rounded-md bg-yellow-600 p-4 mb-8">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
const Alert: React.FC<AlertProps> = ({ title, children, type }) => {
|
||||
let design = {
|
||||
bgColor: 'bg-yellow-600',
|
||||
titleColor: 'text-yellow-200',
|
||||
textColor: 'text-yellow-300',
|
||||
svg: (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case 'info':
|
||||
design = {
|
||||
bgColor: 'bg-indigo-600',
|
||||
titleColor: 'text-indigo-200',
|
||||
textColor: 'text-indigo-300',
|
||||
svg: (
|
||||
<svg
|
||||
className="h-5 w-5 text-yellow-200"
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-md p-4 mb-8 ${design.bgColor}`}>
|
||||
<div className="flex">
|
||||
<div className={`flex-shrink-0 ${design.titleColor}`}>{design.svg}</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-200">{title}</h3>
|
||||
<div className="mt-2 text-sm text-yellow-300">{children}</div>
|
||||
<h3 className={`text-sm font-medium ${design.titleColor}`}>
|
||||
{title}
|
||||
</h3>
|
||||
<div className={`mt-2 text-sm ${design.textColor}`}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,7 +51,7 @@ const Button: React.FC<ButtonProps> = ({
|
||||
break;
|
||||
default:
|
||||
buttonStyle.push(
|
||||
'leading-5 font-medium rounded-md text-gray-200 bg-gray-500 hover:bg-gray-400 hover:text-white focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-400 disabled:opacity-50'
|
||||
'leading-5 font-medium rounded-md text-gray-200 bg-gray-500 hover:bg-gray-400 group-hover:bg-gray-400 hover:text-white group-hover:text-white focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-400 disabled:opacity-50'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const DropdownItem: React.FC<AnchorHTMLAttributes<HTMLAnchorElement>> = ({
|
||||
...props
|
||||
}) => (
|
||||
<a
|
||||
className="cursor-pointer flex items-center px-4 py-2 text-sm leading-5 text-white bg-indigo-600 hover:bg-indigo-500 hover:text-white focus:outline-none focus:border-indigo-700 focus:text-white"
|
||||
className="flex items-center px-4 py-2 text-sm leading-5 text-white bg-indigo-600 cursor-pointer hover:bg-indigo-500 hover:text-white focus:outline-none focus:border-indigo-700 focus:text-white"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -31,6 +31,7 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
|
||||
text,
|
||||
children,
|
||||
dropdownIcon,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -38,22 +39,22 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
|
||||
useClickOutside(buttonRef, () => setIsOpen(false));
|
||||
|
||||
return (
|
||||
<span className="relative z-0 inline-flex shadow-sm rounded-md">
|
||||
<span className="relative z-0 inline-flex rounded-md shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
className={`relative inline-flex items-center px-4 py-2 text-white bg-indigo-600 hover:bg-indigo-500 text-sm leading-5 font-medium hover:text-white focus:ring-indigo active:bg-indigo-700 focus:z-10 focus:outline-none focus:ring-blue transition ease-in-out duration-150 ${
|
||||
children ? 'rounded-l-md' : 'rounded-md'
|
||||
}`}
|
||||
} ${className}`}
|
||||
ref={buttonRef}
|
||||
{...props}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
<span className="-ml-px relative block">
|
||||
<span className="relative block -ml-px">
|
||||
{children && (
|
||||
<button
|
||||
type="button"
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md bg-indigo-700 hover:bg-indigo-500 text-sm leading-5 font-medium text-white focus:z-10 focus:outline-none active:bg-indigo-700 border border-indigo-600 focus:ring-blue transition ease-in-out duration-150"
|
||||
className="relative inline-flex items-center px-2 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out bg-indigo-700 border border-indigo-600 rounded-r-md hover:bg-indigo-500 focus:z-10 focus:outline-none active:bg-indigo-700 focus:ring-blue"
|
||||
aria-label="Expand"
|
||||
onClick={() => setIsOpen((state) => !state)}
|
||||
>
|
||||
@@ -61,7 +62,7 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
|
||||
dropdownIcon
|
||||
) : (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
@@ -84,8 +85,8 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<div className="origin-top-right absolute right-0 mt-2 -mr-1 w-56 rounded-md shadow-lg">
|
||||
<div className="rounded-md bg-indigo-600 ring-1 ring-black ring-opacity-5">
|
||||
<div className="absolute right-0 w-56 mt-2 -mr-1 origin-top-right rounded-md shadow-lg">
|
||||
<div className="bg-indigo-600 rounded-md ring-1 ring-black ring-opacity-5">
|
||||
<div className="py-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,12 +8,18 @@ import React, {
|
||||
interface ImageFaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||
backgroundImages: string[];
|
||||
rotationSpeed?: number;
|
||||
isDarker?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_ROTATION_SPEED = 6000;
|
||||
|
||||
const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
|
||||
{ backgroundImages, rotationSpeed = DEFAULT_ROTATION_SPEED, ...props },
|
||||
{
|
||||
backgroundImages,
|
||||
rotationSpeed = DEFAULT_ROTATION_SPEED,
|
||||
isDarker,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [activeIndex, setIndex] = useState(0);
|
||||
@@ -29,6 +35,14 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
|
||||
};
|
||||
}, [backgroundImages, rotationSpeed]);
|
||||
|
||||
let gradient =
|
||||
'linear-gradient(180deg, rgba(45, 55, 72, 0.47) 0%, #1A202E 100%)';
|
||||
|
||||
if (isDarker) {
|
||||
gradient =
|
||||
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)';
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{backgroundImages.map((imageUrl, i) => (
|
||||
@@ -38,7 +52,7 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
|
||||
i === activeIndex ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(180deg, rgba(45, 55, 72, 0.47) 0%, #1A202E 100%), url(${imageUrl})`,
|
||||
backgroundImage: `${gradient}, url(${imageUrl})`,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -7,7 +7,7 @@ interface ListItemProps {
|
||||
|
||||
const ListItem: React.FC<ListItemProps> = ({ title, children }) => {
|
||||
return (
|
||||
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<div className="py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt className="text-sm font-medium text-gray-200">{title}</dt>
|
||||
<dd className="mt-1 flex text-sm text-gray-400 sm:mt-0 sm:col-span-2">
|
||||
<span className="flex-grow">{children}</span>
|
||||
|
||||
@@ -98,7 +98,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||
show={!loading}
|
||||
>
|
||||
<div
|
||||
className="inline-block align-bottom bg-gray-700 sm:rounded-lg px-4 pt-5 pb-4 text-left overflow-auto shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-3xl w-full sm:p-6 max-h-full"
|
||||
className="inline-block align-bottom bg-gray-700 sm:rounded-lg px-4 pt-5 pb-4 text-left overflow-auto shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-3xl w-full max-h-full"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline"
|
||||
@@ -166,7 +166,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||
<Button
|
||||
buttonType={cancelButtonType}
|
||||
onClick={onCancel}
|
||||
className="ml-3 sm:ml-0 sm:px-4"
|
||||
className="ml-3 sm:ml-0"
|
||||
>
|
||||
{cancelText
|
||||
? cancelText
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import Transition from '../../Transition';
|
||||
@@ -49,7 +48,7 @@ const SlideOver: React.FC<SlideOverProps> = ({
|
||||
className={`z-50 fixed inset-0 overflow-hidden bg-opacity-50 bg-gray-800`}
|
||||
>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<section className="absolute inset-y-0 right-0 pl-10 max-w-full flex">
|
||||
<section className="absolute inset-y-0 right-0 flex max-w-full pl-10">
|
||||
<Transition
|
||||
show={show}
|
||||
appear
|
||||
@@ -61,20 +60,20 @@ const SlideOver: React.FC<SlideOverProps> = ({
|
||||
leaveTo="translate-x-full"
|
||||
>
|
||||
<div className="w-screen max-w-md" ref={slideoverRef}>
|
||||
<div className="h-full flex flex-col bg-gray-700 shadow-xl overflow-y-scroll">
|
||||
<header className="space-y-1 py-6 px-4 bg-indigo-600 sm:px-6">
|
||||
<div className="flex flex-col h-full overflow-y-scroll bg-gray-700 shadow-xl">
|
||||
<header className="px-4 py-6 space-y-1 bg-indigo-600">
|
||||
<div className="flex items-center justify-between space-x-3">
|
||||
<h2 className="text-lg leading-7 font-medium text-white">
|
||||
<h2 className="text-lg font-medium leading-7 text-white">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="h-7 flex items-center">
|
||||
<div className="flex items-center h-7">
|
||||
<button
|
||||
aria-label="Close panel"
|
||||
className="text-indigo-200 hover:text-white transition ease-in-out duration-150"
|
||||
className="text-indigo-200 transition duration-150 ease-in-out hover:text-white"
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
className="w-6 h-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -98,7 +97,7 @@ const SlideOver: React.FC<SlideOverProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<div className="relative flex-1 py-6 px-4 sm:px-6 text-white">
|
||||
<div className="relative flex-1 px-4 py-6 text-white">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,8 +71,8 @@ const TD: React.FC<TDProps> = ({
|
||||
const Table: React.FC = ({ children }) => {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="my-2 overflow-x-auto -mx-6 sm:-mx-6 md:mx-4 lg:mx-4">
|
||||
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||
<div className="my-2 overflow-x-auto -mx-6 md:mx-0 lg:mx-0">
|
||||
<div className="py-2 align-middle inline-block min-w-full">
|
||||
<div className="shadow overflow-hidden sm:rounded-lg">
|
||||
<table className="min-w-full">{children}</table>
|
||||
</div>
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { useSWRInfinite } from 'swr';
|
||||
import type { MovieResult } from '../../../../server/models/Search';
|
||||
import ListView from '../../Common/ListView';
|
||||
import { LanguageContext } from '../../../context/LanguageContext';
|
||||
import Header from '../../Common/Header';
|
||||
|
||||
interface SearchResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: MovieResult[];
|
||||
}
|
||||
|
||||
const Holiday: React.FC = () => {
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/v1/discover/keyword/207317/movies?page=${
|
||||
pageIndex + 1
|
||||
}&language=${locale}`;
|
||||
},
|
||||
{
|
||||
initialSize: 3,
|
||||
}
|
||||
);
|
||||
|
||||
const isLoadingInitialData = !data && !error;
|
||||
const isLoadingMore =
|
||||
isLoadingInitialData ||
|
||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
||||
|
||||
const fetchMore = () => {
|
||||
setSize(size + 1);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <div>{error}</div>;
|
||||
}
|
||||
|
||||
const titles = data?.reduce(
|
||||
(a, v) => [...a, ...v.results],
|
||||
[] as MovieResult[]
|
||||
);
|
||||
|
||||
const isEmpty = !isLoadingInitialData && titles?.length === 0;
|
||||
const isReachingEnd =
|
||||
isEmpty || (data && data[data.length - 1]?.results.length < 20);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>Happy Holidays!</Header>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Holiday;
|
||||
@@ -63,12 +63,6 @@ const Discover: React.FC = () => {
|
||||
} = useSWR<MovieDiscoverResult>(
|
||||
`/api/v1/discover/movies/upcoming?language=${locale}`
|
||||
);
|
||||
const {
|
||||
data: holUpcomingData,
|
||||
error: holUpcomingError,
|
||||
} = useSWR<MovieDiscoverResult>(
|
||||
`/api/v1/discover/keyword/207317/movies?language=${locale}`
|
||||
);
|
||||
|
||||
const { data: trendingData, error: trendingError } = useSWR<MixedResult>(
|
||||
`/api/v1/discover/trending?language=${locale}`
|
||||
@@ -87,9 +81,9 @@ const Discover: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
|
||||
<div className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>
|
||||
<FormattedMessage {...messages.recentlyAdded} />
|
||||
</span>
|
||||
@@ -108,10 +102,10 @@ const Discover: React.FC = () => {
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link href="/requests">
|
||||
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
|
||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>
|
||||
<FormattedMessage {...messages.recentrequests} />
|
||||
</span>
|
||||
@@ -146,61 +140,10 @@ const Discover: React.FC = () => {
|
||||
placeholder={<RequestCard.Placeholder />}
|
||||
emptyMessage={intl.formatMessage(messages.nopending)}
|
||||
/>
|
||||
{/* Special Temporary Slider */}
|
||||
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link href="/discover/holiday">
|
||||
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
|
||||
<span>
|
||||
<span role="img" aria-label="Christmas Tree" className="mr-2">
|
||||
🎄
|
||||
</span>
|
||||
Happy Holidays!
|
||||
<span role="img" aria-label="Christmas Tree" className="ml-2">
|
||||
🎄
|
||||
</span>
|
||||
</span>
|
||||
<svg
|
||||
className="w-6 h-6 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="holiday"
|
||||
isLoading={!holUpcomingData && !holUpcomingError}
|
||||
isEmpty={false}
|
||||
items={holUpcomingData?.results.map((title) => (
|
||||
<TitleCard
|
||||
key={`holiday-movie-slider-${title.id}`}
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
title={title.title}
|
||||
userScore={title.voteAverage}
|
||||
year={title.releaseDate}
|
||||
mediaType={title.mediaType}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
{/* End Special Temporary Slider */}
|
||||
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link href="/discover/movies/upcoming">
|
||||
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
|
||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>
|
||||
<FormattedMessage {...messages.upcoming} />
|
||||
</span>
|
||||
@@ -240,10 +183,10 @@ const Discover: React.FC = () => {
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link href="/discover/trending">
|
||||
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
|
||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>
|
||||
<FormattedMessage {...messages.trending} />
|
||||
</span>
|
||||
@@ -308,10 +251,10 @@ const Discover: React.FC = () => {
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link href="/discover/movies">
|
||||
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
|
||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>
|
||||
<FormattedMessage {...messages.popularmovies} />
|
||||
</span>
|
||||
@@ -351,10 +294,10 @@ const Discover: React.FC = () => {
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
<div className="md:flex md:items-center md:justify-between mb-4 mt-4">
|
||||
<div className="mt-4 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link href="/discover/tv">
|
||||
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
|
||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>
|
||||
<FormattedMessage {...messages.populartv} />
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import useSearchInput from '../../../hooks/useSearchInput';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import ClearButton from '../../../assets/xcircle.svg';
|
||||
|
||||
const messages = defineMessages({
|
||||
searchPlaceholder: 'Search Movies & TV',
|
||||
@@ -8,16 +9,16 @@ const messages = defineMessages({
|
||||
|
||||
const SearchInput: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { searchValue, setSearchValue, setIsOpen } = useSearchInput();
|
||||
const { searchValue, setSearchValue, setIsOpen, clear } = useSearchInput();
|
||||
return (
|
||||
<div className="flex-1 flex">
|
||||
<div className="w-full flex md:ml-0">
|
||||
<div className="flex flex-1">
|
||||
<div className="flex w-full md:ml-0">
|
||||
<label htmlFor="search_field" className="sr-only">
|
||||
Search
|
||||
</label>
|
||||
<div className="relative w-full text-white focus-within:text-gray-200">
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none">
|
||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
@@ -27,14 +28,27 @@ const SearchInput: React.FC = () => {
|
||||
</div>
|
||||
<input
|
||||
id="search_field"
|
||||
className="block w-full h-full pl-8 pr-1 py-2 rounded-md border-transparent focus:border-transparent bg-gray-600 text-white placeholder-gray-300 focus:outline-none focus:ring-0 focus:placeholder-gray-400 sm:text-base"
|
||||
style={{ paddingRight: searchValue.length > 0 ? '1.75rem' : '' }}
|
||||
className="block w-full h-full py-2 pl-8 text-white placeholder-gray-300 bg-gray-600 border-transparent rounded-md focus:border-transparent focus:outline-none focus:ring-0 focus:placeholder-gray-400 sm:text-base"
|
||||
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
||||
type="search"
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onBlur={() => setIsOpen(false)}
|
||||
onBlur={() => {
|
||||
if (searchValue === '') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{searchValue.length > 0 && (
|
||||
<button
|
||||
className="absolute inset-y-0 right-0 p-1 m-auto text-gray-400 transition border-none outline-none h-7 w-7 focus:outline-none focus:border-none hover:text-white"
|
||||
onClick={() => clear()}
|
||||
>
|
||||
<ClearButton />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -175,11 +175,9 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
|
||||
>
|
||||
<div className="flex-shrink-0 flex items-center px-4">
|
||||
<span className="text-xl text-gray-50">
|
||||
<Link href="/">
|
||||
<a>
|
||||
<img src="/logo.png" alt="Overseerr Logo" />
|
||||
</a>
|
||||
</Link>
|
||||
<a href="/">
|
||||
<img src="/logo.png" alt="Overseerr Logo" />
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<nav className="mt-5 px-2 space-y-1">
|
||||
@@ -239,11 +237,9 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
|
||||
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||
<div className="flex items-center flex-shrink-0 px-4">
|
||||
<span className="text-2xl text-gray-50">
|
||||
<Link href="/">
|
||||
<a>
|
||||
<img src="/logo.png" alt="Overseerr Logo" />
|
||||
</a>
|
||||
</Link>
|
||||
<a href="/">
|
||||
<img src="/logo.png" alt="Overseerr Logo" />
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<nav className="mt-5 flex-1 px-2 bg-gray-800 space-y-1">
|
||||
|
||||
@@ -18,18 +18,18 @@ const Layout: React.FC = ({ children }) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="min-h-full h-full flex bg-gray-900">
|
||||
<div className="flex h-full min-w-0 min-h-full bg-gray-900">
|
||||
<Sidebar open={isSidebarOpen} setClosed={() => setSidebarOpen(false)} />
|
||||
|
||||
<div className="flex flex-col w-0 flex-1 md:ml-64 relative mb-16">
|
||||
<div className="z-10 flex-shrink-0 flex h-16 bg-gray-600 shadow fixed right-0 left-0 md:left-64">
|
||||
<div className="relative flex flex-col flex-1 w-0 min-w-0 mb-16 md:ml-64">
|
||||
<div className="fixed left-0 right-0 z-10 flex flex-shrink-0 h-16 bg-gray-600 shadow md:left-64">
|
||||
<button
|
||||
className="px-4 border-r border-gray-800 text-gray-200 focus:outline-none focus:bg-gray-300 focus:text-gray-600 md:hidden"
|
||||
className="px-4 text-gray-200 border-r border-gray-800 focus:outline-none focus:bg-gray-300 focus:text-gray-600 md:hidden"
|
||||
aria-label="Open sidebar"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
className="w-6 h-6"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -42,7 +42,7 @@ const Layout: React.FC = ({ children }) => {
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex-1 px-4 flex justify-between">
|
||||
<div className="flex justify-between flex-1 px-4">
|
||||
<SearchInput />
|
||||
<div className="flex items-center md:ml-6">
|
||||
<LanguagePicker />
|
||||
@@ -51,18 +51,15 @@ const Layout: React.FC = ({ children }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main
|
||||
className="relative z-0 top-16 focus:outline-none right-0"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="pt-2 pb-6 md:py-6">
|
||||
<div className="max-w-8xl mx-auto px-4 sm:px-6 md:px-8">
|
||||
<main className="relative z-0 top-16 focus:outline-none" tabIndex={0}>
|
||||
<div className="pt-2 pb-6">
|
||||
<div className="px-4 mx-auto max-w-8xl">
|
||||
{router.pathname === '/' && hasPermission(Permission.ADMIN) && (
|
||||
<div className="rounded-md bg-indigo-700 p-4 mt-2">
|
||||
<div className="p-4 mt-2 bg-indigo-700 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="h-5 w-5 text-white"
|
||||
className="w-5 h-5 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
@@ -74,14 +71,14 @@ const Layout: React.FC = ({ children }) => {
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3 flex-1 md:flex md:justify-between">
|
||||
<div className="flex-1 ml-3 md:flex md:justify-between">
|
||||
<p className="text-sm leading-5 text-white">
|
||||
<FormattedMessage {...messages.alphawarning} />
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-5 md:mt-0 md:ml-6">
|
||||
<a
|
||||
href="http://github.com/sct/overseerr"
|
||||
className="whitespace-nowrap font-medium text-indigo-100 hover:text-white transition ease-in-out duration-150"
|
||||
className="font-medium text-indigo-100 transition duration-150 ease-in-out whitespace-nowrap hover:text-white"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -51,31 +51,33 @@ const Login: React.FC = () => {
|
||||
}, [user, router]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex flex-col justify-center py-12 sm:px-6 lg:px-8 relative">
|
||||
<div className="relative flex flex-col justify-center min-h-screen py-12 bg-gray-900">
|
||||
<ImageFader
|
||||
backgroundImages={[
|
||||
'/images/rotate1.jpg',
|
||||
'/images/rotate2.jpg',
|
||||
'/images/rotate3.jpg',
|
||||
'/images/rotate4.jpg',
|
||||
'/images/rotate5.jpg',
|
||||
'/images/rotate6.jpg',
|
||||
]}
|
||||
/>
|
||||
<div className="absolute top-4 right-4 z-50">
|
||||
<div className="absolute z-50 top-4 right-4">
|
||||
<LanguagePicker />
|
||||
</div>
|
||||
<div className="px-4 sm:px-2 md:px-0 sm:mx-auto sm:w-full sm:max-w-md relative z-40">
|
||||
<div className="relative z-40 px-4 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<img
|
||||
src="/logo.png"
|
||||
className="mx-auto max-h-32 w-auto"
|
||||
className="w-auto mx-auto max-h-32"
|
||||
alt="Overseerr Logo"
|
||||
/>
|
||||
<h2 className="mt-2 text-center text-3xl leading-9 font-extrabold text-gray-100">
|
||||
<h2 className="mt-2 text-3xl font-extrabold leading-9 text-center text-gray-100">
|
||||
<FormattedMessage {...messages.signinplex} />
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md relative z-50">
|
||||
<div className="relative z-50 mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div
|
||||
className="bg-gray-800 bg-opacity-50 py-8 px-4 shadow sm:rounded-lg sm:px-10"
|
||||
className="px-4 py-8 bg-gray-800 bg-opacity-50 shadow sm:rounded-lg"
|
||||
style={{ backdropFilter: 'blur(5px)' }}
|
||||
>
|
||||
<Transition
|
||||
@@ -87,11 +89,11 @@ const Login: React.FC = () => {
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="rounded-md bg-red-600 p-4 mb-4">
|
||||
<div className="p-4 mb-4 bg-red-600 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="h-5 w-5 text-red-300"
|
||||
className="w-5 h-5 text-red-300"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
|
||||
@@ -42,11 +42,11 @@ const MovieCast: React.FC = () => {
|
||||
{intl.formatMessage(messages.fullcast)}
|
||||
</Header>
|
||||
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
|
||||
{data?.credits.cast.map((person) => {
|
||||
{data?.credits.cast.map((person, index) => {
|
||||
return (
|
||||
<li
|
||||
key={person.id}
|
||||
className="col-span-1 flex flex-col text-center items-center"
|
||||
key={`cast-${person.id}-${index}`}
|
||||
className="flex flex-col items-center col-span-1 text-center"
|
||||
>
|
||||
<PersonCard
|
||||
name={person.name}
|
||||
|
||||
66
src/components/MovieDetails/MovieCrew/index.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useContext } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import { MovieDetails } from '../../../../server/models/Movie';
|
||||
import { LanguageContext } from '../../../context/LanguageContext';
|
||||
import Error from '../../../pages/_error';
|
||||
import Header from '../../Common/Header';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import PersonCard from '../../PersonCard';
|
||||
|
||||
const messages = defineMessages({
|
||||
fullcrew: 'Full Crew',
|
||||
});
|
||||
|
||||
const MovieCrew: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error } = useSWR<MovieDetails>(
|
||||
`/api/v1/movie/${router.query.movieId}?language=${locale}`
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Error statusCode={404} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
subtext={
|
||||
<Link href={`/movie/${data.id}`}>
|
||||
<a className="hover:underline">{data.title}</a>
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{intl.formatMessage(messages.fullcrew)}
|
||||
</Header>
|
||||
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
|
||||
{data?.credits.crew.map((person, index) => {
|
||||
return (
|
||||
<li
|
||||
key={`crew-${person.id}-${index}`}
|
||||
className="flex flex-col items-center col-span-1 text-center"
|
||||
>
|
||||
<PersonCard
|
||||
name={person.name}
|
||||
personId={person.id}
|
||||
subName={person.job}
|
||||
profilePath={person.profilePath}
|
||||
canExpand
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MovieCrew;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useContext } from 'react';
|
||||
import React, { useState, useContext, useMemo } from 'react';
|
||||
import {
|
||||
FormattedMessage,
|
||||
defineMessages,
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
MediaRequestStatus,
|
||||
} from '../../../server/constants/media';
|
||||
import RequestModal from '../RequestModal';
|
||||
import Badge from '../Common/Badge';
|
||||
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
|
||||
import axios from 'axios';
|
||||
import SlideOver from '../Common/SlideOver';
|
||||
@@ -36,8 +35,9 @@ import RTAudRotten from '../../assets/rt_aud_rotten.svg';
|
||||
import type { RTRating } from '../../../server/api/rottentomatoes';
|
||||
import Error from '../../pages/_error';
|
||||
import Head from 'next/head';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import ExternalLinkBlock from '../ExternalLinkBlock';
|
||||
import { sortCrewPriority } from '../../utils/creditHelpers';
|
||||
import StatusBadge from '../StatusBadge';
|
||||
|
||||
const messages = defineMessages({
|
||||
releasedate: 'Release Date',
|
||||
@@ -45,6 +45,7 @@ const messages = defineMessages({
|
||||
status: 'Status',
|
||||
revenue: 'Revenue',
|
||||
budget: 'Budget',
|
||||
watchtrailer: 'Watch Trailer',
|
||||
originallanguage: 'Original Language',
|
||||
overview: 'Overview',
|
||||
runtime: '{minutes} minutes',
|
||||
@@ -67,6 +68,8 @@ const messages = defineMessages({
|
||||
approve: 'Approve',
|
||||
decline: 'Decline',
|
||||
studio: 'Studio',
|
||||
viewfullcrew: 'View Full Crew',
|
||||
view: 'View',
|
||||
});
|
||||
|
||||
interface MovieDetailsProps {
|
||||
@@ -103,6 +106,10 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
`/api/v1/movie/${router.query.movieId}/ratings`
|
||||
);
|
||||
|
||||
const sortedCrew = useMemo(() => sortCrewPriority(data?.credits.crew ?? []), [
|
||||
data,
|
||||
]);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -115,6 +122,11 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
(request) => request.status === MediaRequestStatus.PENDING
|
||||
);
|
||||
|
||||
const trailerUrl = data.relatedVideos
|
||||
?.filter((r) => r.type === 'Trailer')
|
||||
.sort((a, b) => a.size - b.size)
|
||||
.pop()?.url;
|
||||
|
||||
const modifyRequest = async (type: 'approve' | 'decline') => {
|
||||
const response = await axios.get(
|
||||
`/api/v1/request/${activeRequest?.id}/${type}`
|
||||
@@ -134,7 +146,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-cover bg-center -mx-4 -mt-2 px-4 sm:px-8 pt-4 "
|
||||
className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover"
|
||||
style={{
|
||||
height: 493,
|
||||
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
|
||||
@@ -159,21 +171,21 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
onClose={() => setShowManager(false)}
|
||||
subText={data.title}
|
||||
>
|
||||
<h3 className="text-xl mb-2">
|
||||
<h3 className="mb-2 text-xl">
|
||||
{intl.formatMessage(messages.manageModalRequests)}
|
||||
</h3>
|
||||
<div className="bg-gray-600 shadow overflow-hidden rounded-md">
|
||||
<div className="overflow-hidden bg-gray-600 rounded-md shadow">
|
||||
<ul>
|
||||
{data.mediaInfo?.requests?.map((request) => (
|
||||
<li
|
||||
key={`manage-request-${request.id}`}
|
||||
className="border-b last:border-b-0 border-gray-700"
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<RequestBlock request={request} onUpdate={() => revalidate()} />
|
||||
</li>
|
||||
))}
|
||||
{(data.mediaInfo?.requests ?? []).length === 0 && (
|
||||
<li className="text-center py-4 text-gray-400">
|
||||
<li className="py-4 text-center text-gray-400">
|
||||
{intl.formatMessage(messages.manageModalNoRequests)}
|
||||
</li>
|
||||
)}
|
||||
@@ -188,43 +200,29 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
>
|
||||
{intl.formatMessage(messages.manageModalClearMedia)}
|
||||
</Button>
|
||||
<div className="text-sm text-gray-400 mt-2">
|
||||
<div className="mt-2 text-sm text-gray-400">
|
||||
{intl.formatMessage(messages.manageModalClearMediaWarning)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SlideOver>
|
||||
<div className="flex flex-col items-center md:flex-row md:items-end pt-4">
|
||||
<div className="md:mr-4 flex-shrink-0">
|
||||
<div className="flex flex-col items-center pt-4 lg:flex-row lg:items-end">
|
||||
<div className="lg:mr-4">
|
||||
<img
|
||||
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
|
||||
alt=""
|
||||
className="rounded md:rounded-lg shadow md:shadow-2xl w-32 md:w-52"
|
||||
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-44 lg:w-52"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-white flex flex-col md:mr-4 mt-4 md:mt-0 text-center md:text-left">
|
||||
<div className="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left">
|
||||
<div className="mb-2">
|
||||
{data.mediaInfo?.status === MediaStatus.AVAILABLE && (
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(globalMessages.available)}
|
||||
</Badge>
|
||||
)}
|
||||
{data.mediaInfo?.status === MediaStatus.PROCESSING && (
|
||||
<Badge badgeType="danger">
|
||||
{intl.formatMessage(globalMessages.unavailable)}
|
||||
</Badge>
|
||||
)}
|
||||
{data.mediaInfo?.status === MediaStatus.PENDING && (
|
||||
<Badge badgeType="warning">
|
||||
{intl.formatMessage(globalMessages.pending)}
|
||||
</Badge>
|
||||
)}
|
||||
<StatusBadge status={data.mediaInfo?.status} />
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-4xl">
|
||||
<h1 className="text-2xl lg:text-4xl">
|
||||
{data.title}{' '}
|
||||
<span className="text-2xl">({data.releaseDate.slice(0, 4)})</span>
|
||||
</h1>
|
||||
<span className="text-xs md:text-base mt-1 md:mt-0">
|
||||
<span className="mt-1 text-xs lg:text-base lg:mt-0">
|
||||
{(data.runtime ?? 0) > 0 && (
|
||||
<>
|
||||
<FormattedMessage
|
||||
@@ -237,16 +235,44 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
{data.genres.map((g) => g.name).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 flex justify-end mt-4 md:mt-0">
|
||||
<div className="flex justify-end flex-shrink-0 mt-4 lg:mt-0">
|
||||
{trailerUrl && (
|
||||
<a href={trailerUrl} target={'_blank'} rel="noreferrer">
|
||||
<Button buttonType="ghost">
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedMessage {...messages.watchtrailer} />
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{(!data.mediaInfo ||
|
||||
data.mediaInfo?.status === MediaStatus.UNKNOWN) && (
|
||||
<Button
|
||||
buttonType="primary"
|
||||
className="ml-2"
|
||||
onClick={() => setShowRequestModal(true)}
|
||||
>
|
||||
{activeRequest ? (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
className="w-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -259,7 +285,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
className="w-5 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -310,6 +336,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
</>
|
||||
}
|
||||
onClick={() => setShowRequestModal(true)}
|
||||
className="ml-2"
|
||||
>
|
||||
{hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<>
|
||||
@@ -382,7 +409,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex pt-8 text-white flex-col md:flex-row pb-4">
|
||||
<div className="flex flex-col pt-8 pb-4 text-white md:flex-row">
|
||||
<div className="flex-1 md:mr-8">
|
||||
<h2 className="text-xl md:text-2xl">
|
||||
<FormattedMessage {...messages.overview} />
|
||||
@@ -392,11 +419,70 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
? data.overview
|
||||
: intl.formatMessage(messages.overviewunavailable)}
|
||||
</p>
|
||||
<ul className="grid grid-cols-2 gap-6 mt-6 sm:grid-cols-3">
|
||||
{sortedCrew.slice(0, 6).map((person) => (
|
||||
<li
|
||||
className="flex flex-col col-span-1"
|
||||
key={`crew-${person.job}-${person.id}`}
|
||||
>
|
||||
<span className="font-bold">{person.job}</span>
|
||||
<Link href={`/person/${person.id}`}>
|
||||
<a className="text-gray-400 transition duration-300 hover:text-underline hover:text-gray-100">
|
||||
{person.name}
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{sortedCrew.length > 0 && (
|
||||
<div className="flex justify-end mt-4">
|
||||
<Link href={`/movie/${data.id}/crew`}>
|
||||
<a className="flex items-center text-gray-400 transition duration-300 hover:text-gray-100">
|
||||
<span>{intl.formatMessage(messages.viewfullcrew)}</span>
|
||||
<svg
|
||||
className="inline-block w-5 h-5 ml-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full md:w-80 mt-8 md:mt-0">
|
||||
<div className="bg-gray-900 rounded-lg shadow border border-gray-800">
|
||||
<div className="w-full mt-8 md:w-80 md:mt-0">
|
||||
{data.collection && (
|
||||
<div className="mb-6">
|
||||
<Link href={`/collection/${data.collection.id}`}>
|
||||
<a>
|
||||
<div
|
||||
className="relative transition duration-300 transform scale-100 bg-gray-800 bg-center bg-cover rounded-lg shadow-md cursor-pointer group hover:scale-105"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(180deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 0.80) 100%), url(//image.tmdb.org/t/p/w1440_and_h320_multi_faces/${data.collection.backdropPath})`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 text-gray-200 transition duration-300 h-14 group-hover:text-white">
|
||||
<div>{data.collection.name}</div>
|
||||
<Button buttonSize="sm">
|
||||
{intl.formatMessage(messages.view)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg shadow">
|
||||
{(data.voteCount > 0 || ratingData) && (
|
||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0 items-center justify-center">
|
||||
<div className="flex items-center justify-center px-4 py-2 border-b border-gray-800 last:border-b-0">
|
||||
{ratingData?.criticsRating &&
|
||||
(ratingData?.criticsScore ?? 0) > 0 && (
|
||||
<>
|
||||
@@ -407,7 +493,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
<RTFresh className="w-6 mr-1" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-gray-400 text-sm mr-4 last:mr-0">
|
||||
<span className="mr-4 text-sm text-gray-400 last:mr-0">
|
||||
{ratingData.criticsScore}%
|
||||
</span>
|
||||
</>
|
||||
@@ -422,7 +508,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
<RTAudFresh className="w-6 mr-1" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-gray-400 text-sm mr-4 last:mr-0">
|
||||
<span className="mr-4 text-sm text-gray-400 last:mr-0">
|
||||
{ratingData.audienceScore}%
|
||||
</span>
|
||||
</>
|
||||
@@ -432,7 +518,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
<span className="text-sm">
|
||||
<TmdbLogo className="w-6 mr-2" />
|
||||
</span>
|
||||
<span className="text-gray-400 text-sm">
|
||||
<span className="text-sm text-gray-400">
|
||||
{data.voteAverage}/10
|
||||
</span>
|
||||
</>
|
||||
@@ -443,7 +529,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
<span className="text-sm">
|
||||
<FormattedMessage {...messages.releasedate} />
|
||||
</span>
|
||||
<span className="flex-1 text-right text-gray-400 text-sm">
|
||||
<span className="flex-1 text-sm text-right text-gray-400">
|
||||
<FormattedDate
|
||||
value={new Date(data.releaseDate)}
|
||||
year="numeric"
|
||||
@@ -456,7 +542,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
<span className="text-sm">
|
||||
<FormattedMessage {...messages.status} />
|
||||
</span>
|
||||
<span className="flex-1 text-right text-gray-400 text-sm">
|
||||
<span className="flex-1 text-sm text-right text-gray-400">
|
||||
{data.status}
|
||||
</span>
|
||||
</div>
|
||||
@@ -465,7 +551,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
<span className="text-sm">
|
||||
<FormattedMessage {...messages.revenue} />
|
||||
</span>
|
||||
<span className="flex-1 text-right text-gray-400 text-sm">
|
||||
<span className="flex-1 text-sm text-right text-gray-400">
|
||||
<FormattedNumber
|
||||
currency="USD"
|
||||
style="currency"
|
||||
@@ -479,7 +565,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
<span className="text-sm">
|
||||
<FormattedMessage {...messages.budget} />
|
||||
</span>
|
||||
<span className="flex-1 text-right text-gray-400 text-sm">
|
||||
<span className="flex-1 text-sm text-right text-gray-400">
|
||||
<FormattedNumber
|
||||
currency="USD"
|
||||
style="currency"
|
||||
@@ -495,7 +581,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
<span className="text-sm">
|
||||
<FormattedMessage {...messages.originallanguage} />
|
||||
</span>
|
||||
<span className="flex-1 text-right text-gray-400 text-sm">
|
||||
<span className="flex-1 text-sm text-right text-gray-400">
|
||||
{
|
||||
data.spokenLanguages.find(
|
||||
(lng) => lng.iso_639_1 === data.originalLanguage
|
||||
@@ -509,7 +595,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
<span className="text-sm">
|
||||
<FormattedMessage {...messages.studio} />
|
||||
</span>
|
||||
<span className="flex-1 text-right text-gray-400 text-sm">
|
||||
<span className="flex-1 text-sm text-right text-gray-400">
|
||||
{data.productionCompanies[0]?.name}
|
||||
</span>
|
||||
</div>
|
||||
@@ -525,10 +611,10 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link href="/movie/[movieId]/cast" as={`/movie/${data.id}/cast`}>
|
||||
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
|
||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>
|
||||
<FormattedMessage {...messages.cast} />
|
||||
</span>
|
||||
@@ -566,13 +652,13 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
/>
|
||||
{(recommended?.results ?? []).length > 0 && (
|
||||
<>
|
||||
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
href="/movie/[movieId]/recommendations"
|
||||
as={`/movie/${data.id}/recommendations`}
|
||||
>
|
||||
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
|
||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>
|
||||
<FormattedMessage {...messages.recommendations} />
|
||||
</span>
|
||||
@@ -616,13 +702,13 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
)}
|
||||
{(similar?.results ?? []).length > 0 && (
|
||||
<>
|
||||
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
href="/movie/[movieId]/similar"
|
||||
as={`/movie/${data.id}/similar`}
|
||||
>
|
||||
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
|
||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>
|
||||
<FormattedMessage {...messages.similar} />
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { NotificationItem, hasNotificationType } from '..';
|
||||
|
||||
interface NotificationTypeProps {
|
||||
option: NotificationItem;
|
||||
currentTypes: number;
|
||||
parent?: NotificationItem;
|
||||
onUpdate: (newTypes: number) => void;
|
||||
}
|
||||
|
||||
const NotificationType: React.FC<NotificationTypeProps> = ({
|
||||
option,
|
||||
currentTypes,
|
||||
onUpdate,
|
||||
parent,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`relative flex items-start first:mt-0 mt-4 ${
|
||||
!!parent?.value && hasNotificationType(parent.value, currentTypes)
|
||||
? 'opacity-50'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
id={option.id}
|
||||
name="permissions"
|
||||
type="checkbox"
|
||||
className="w-4 h-4 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
|
||||
disabled={
|
||||
!!parent?.value && hasNotificationType(parent.value, currentTypes)
|
||||
}
|
||||
onClick={() => {
|
||||
onUpdate(
|
||||
hasNotificationType(option.value, currentTypes)
|
||||
? currentTypes - option.value
|
||||
: currentTypes + option.value
|
||||
);
|
||||
}}
|
||||
defaultChecked={
|
||||
hasNotificationType(option.value, currentTypes) ||
|
||||
(!!parent?.value &&
|
||||
hasNotificationType(parent.value, currentTypes))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm leading-5">
|
||||
<label htmlFor={option.id} className="font-medium">
|
||||
{option.name}
|
||||
</label>
|
||||
<p className="text-gray-500">{option.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{(option.children ?? []).map((child) => (
|
||||
<div key={`notification-type-child-${child.id}`} className="pl-6 mt-4">
|
||||
<NotificationType
|
||||
option={child}
|
||||
currentTypes={currentTypes}
|
||||
onUpdate={(newTypes) => onUpdate(newTypes)}
|
||||
parent={option}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationType;
|
||||
106
src/components/NotificationTypeSelector/index.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import NotificationType from './NotificationType';
|
||||
|
||||
const messages = defineMessages({
|
||||
mediarequested: 'Media Requested',
|
||||
mediarequestedDescription:
|
||||
'Sends a notification when new media is requested. For certain agents, this will only send the notification to admins or users with the "Manage Requests" permission.',
|
||||
mediaapproved: 'Media Approved',
|
||||
mediaapprovedDescription: 'Sends a notification when media is approved.',
|
||||
mediaavailable: 'Media Available',
|
||||
mediaavailableDescription:
|
||||
'Sends a notification when media becomes available.',
|
||||
mediafailed: 'Media Failed',
|
||||
mediafailedDescription:
|
||||
'Sends a notification when media fails to be added to services (Radarr/Sonarr). For certain agents, this will only send the notification to admins or users with the "Manage Requests" permission.',
|
||||
});
|
||||
|
||||
export const hasNotificationType = (
|
||||
types: Notification | Notification[],
|
||||
value: number
|
||||
): boolean => {
|
||||
let total = 0;
|
||||
|
||||
if (types === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Array.isArray(types)) {
|
||||
total = types.reduce((a, v) => a + v, 0);
|
||||
} else {
|
||||
total = types;
|
||||
}
|
||||
|
||||
return !!(value & total);
|
||||
};
|
||||
|
||||
export enum Notification {
|
||||
MEDIA_PENDING = 2,
|
||||
MEDIA_APPROVED = 4,
|
||||
MEDIA_AVAILABLE = 8,
|
||||
MEDIA_FAILED = 16,
|
||||
TEST_NOTIFICATION = 32,
|
||||
}
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
value: Notification;
|
||||
children?: NotificationItem[];
|
||||
}
|
||||
|
||||
interface NotificationTypeSelectorProps {
|
||||
currentTypes: number;
|
||||
onUpdate: (newTypes: number) => void;
|
||||
}
|
||||
|
||||
const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
|
||||
currentTypes,
|
||||
onUpdate,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const types: NotificationItem[] = [
|
||||
{
|
||||
id: 'media-requested',
|
||||
name: intl.formatMessage(messages.mediarequested),
|
||||
description: intl.formatMessage(messages.mediarequestedDescription),
|
||||
value: Notification.MEDIA_PENDING,
|
||||
},
|
||||
{
|
||||
id: 'media-approved',
|
||||
name: intl.formatMessage(messages.mediaapproved),
|
||||
description: intl.formatMessage(messages.mediaapprovedDescription),
|
||||
value: Notification.MEDIA_APPROVED,
|
||||
},
|
||||
{
|
||||
id: 'media-available',
|
||||
name: intl.formatMessage(messages.mediaavailable),
|
||||
description: intl.formatMessage(messages.mediaavailableDescription),
|
||||
value: Notification.MEDIA_AVAILABLE,
|
||||
},
|
||||
{
|
||||
id: 'media-failed',
|
||||
name: intl.formatMessage(messages.mediafailed),
|
||||
description: intl.formatMessage(messages.mediafailedDescription),
|
||||
value: Notification.MEDIA_FAILED,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{types.map((type) => (
|
||||
<NotificationType
|
||||
key={`notification-type-${type.id}`}
|
||||
option={type}
|
||||
currentTypes={currentTypes}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationTypeSelector;
|
||||
97
src/components/PermissionOption/index.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import { hasPermission } from '../../../server/lib/permissions';
|
||||
import { Permission, User } from '../../hooks/useUser';
|
||||
|
||||
export interface PermissionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
permission: Permission;
|
||||
children?: PermissionItem[];
|
||||
}
|
||||
|
||||
interface PermissionOptionProps {
|
||||
option: PermissionItem;
|
||||
currentPermission: number;
|
||||
user?: User;
|
||||
parent?: PermissionItem;
|
||||
onUpdate: (newPermissions: number) => void;
|
||||
}
|
||||
|
||||
const PermissionOption: React.FC<PermissionOptionProps> = ({
|
||||
option,
|
||||
currentPermission,
|
||||
onUpdate,
|
||||
user,
|
||||
parent,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`relative flex items-start first:mt-0 mt-4 ${
|
||||
(option.permission !== Permission.ADMIN &&
|
||||
hasPermission(Permission.ADMIN, currentPermission)) ||
|
||||
(!!parent?.permission &&
|
||||
hasPermission(parent.permission, currentPermission)) ||
|
||||
(user && user.id !== 1 && option.permission === Permission.ADMIN) ||
|
||||
(user &&
|
||||
!hasPermission(Permission.MANAGE_SETTINGS, user.permissions) &&
|
||||
option.permission === Permission.MANAGE_SETTINGS)
|
||||
? 'opacity-50'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
id={option.id}
|
||||
name="permissions"
|
||||
type="checkbox"
|
||||
className="w-4 h-4 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
|
||||
disabled={
|
||||
(option.permission !== Permission.ADMIN &&
|
||||
hasPermission(Permission.ADMIN, currentPermission)) ||
|
||||
(!!parent?.permission &&
|
||||
hasPermission(parent.permission, currentPermission)) ||
|
||||
(user &&
|
||||
user.id !== 1 &&
|
||||
option.permission === Permission.ADMIN) ||
|
||||
(user &&
|
||||
!hasPermission(Permission.MANAGE_SETTINGS, user.permissions) &&
|
||||
option.permission === Permission.MANAGE_SETTINGS)
|
||||
}
|
||||
onClick={() => {
|
||||
onUpdate(
|
||||
hasPermission(option.permission, currentPermission)
|
||||
? currentPermission - option.permission
|
||||
: currentPermission + option.permission
|
||||
);
|
||||
}}
|
||||
checked={
|
||||
hasPermission(option.permission, currentPermission) ||
|
||||
(!!parent?.permission &&
|
||||
hasPermission(parent.permission, currentPermission))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm leading-5">
|
||||
<label htmlFor={option.id} className="font-medium">
|
||||
{option.name}
|
||||
</label>
|
||||
<p className="text-gray-500">{option.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{(option.children ?? []).map((child) => (
|
||||
<div key={`permission-child-${child.id}`} className="pl-6 mt-4">
|
||||
<PermissionOption
|
||||
option={child}
|
||||
currentPermission={currentPermission}
|
||||
onUpdate={(newPermission) => onUpdate(newPermission)}
|
||||
parent={option}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PermissionOption;
|
||||
@@ -25,18 +25,19 @@ const PersonCard: React.FC<PersonCardProps> = ({
|
||||
} bg-gray-600 rounded-lg text-white shadow-lg hover:bg-gray-500 transition ease-in-out duration-150 cursor-pointer`}
|
||||
>
|
||||
<div style={{ paddingBottom: '150%' }}>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<div className="absolute inset-0 flex flex-col items-center w-full h-full p-2">
|
||||
{profilePath && (
|
||||
<div
|
||||
style={{
|
||||
backgroundImage: `url(https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath})`,
|
||||
}}
|
||||
className="rounded-full w-28 h-28 md:w-32 md:h-32 bg-cover bg-center mb-6"
|
||||
/>
|
||||
<div className="relative flex justify-center w-full mt-2 mb-4 h-1/2">
|
||||
<img
|
||||
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`}
|
||||
className="object-cover w-3/4 h-full bg-center bg-cover rounded-full"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!profilePath && (
|
||||
<svg
|
||||
className="w-28 h-28 md:w-32 md:h-32 mb-6"
|
||||
className="mb-6 w-28 h-28 md:w-32 md:h-32"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -48,12 +49,21 @@ const PersonCard: React.FC<PersonCardProps> = ({
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<div className="whitespace-normal text-center">{name}</div>
|
||||
<div className="w-full text-center truncate">{name}</div>
|
||||
{subName && (
|
||||
<div className="whitespace-normal text-center text-sm text-gray-300">
|
||||
<div
|
||||
className="overflow-hidden text-sm text-center text-gray-300 whitespace-normal"
|
||||
style={{
|
||||
WebkitLineClamp: 2,
|
||||
display: '-webkit-box',
|
||||
overflow: 'hidden',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{subName}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-12 rounded-b-lg bg-gradient-to-t from-gray-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||