Compare commits
331 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6486c3643 | ||
|
|
f9695068a8 | ||
|
|
2bfe0f2bf6 | ||
|
|
493d82b6b0 | ||
|
|
e2c2172ac6 | ||
|
|
ee5d0181fc | ||
|
|
67f2b57f00 | ||
|
|
88810bf0a4 | ||
|
|
0ab4c3c36f | ||
|
|
effc80977a | ||
|
|
fd90adba4d | ||
|
|
970aa612fe | ||
|
|
71169944dc | ||
|
|
b04eda6c8a | ||
|
|
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 | ||
|
|
0a3d7c5915 | ||
|
|
912c63e562 | ||
|
|
f6865ca0c1 | ||
|
|
c9fe6cb0b7 | ||
|
|
3778ad829c | ||
|
|
b1786245c7 | ||
|
|
b7f5739035 | ||
|
|
c128898206 | ||
|
|
ff2ab29491 | ||
|
|
108dfc4afd | ||
|
|
6f2de8577e | ||
|
|
1bd0e646e3 | ||
|
|
077513151e | ||
|
|
191f39e8bd | ||
|
|
fb883e8043 | ||
|
|
4435468561 | ||
|
|
e821d0a02b | ||
|
|
5f71fb7ee2 | ||
|
|
a0c5608aa0 | ||
|
|
816fec1a83 | ||
|
|
2a4dd52275 | ||
|
|
3601d442db | ||
|
|
53bede692d | ||
|
|
fe46895289 | ||
|
|
a0024a0cbe | ||
|
|
8408e19568 | ||
|
|
ed49e7c28e | ||
|
|
549567a7e9 | ||
|
|
51f99a2e7b | ||
|
|
048b6c1d90 | ||
|
|
a6a065ac0b | ||
|
|
5e5ba40505 | ||
|
|
abb0b2d763 | ||
|
|
d2668d3f49 | ||
|
|
36b2b4fba4 | ||
|
|
6beac736ef | ||
|
|
6933b661ca | ||
|
|
4b6ad8a387 | ||
|
|
992a23222a | ||
|
|
e75b592bb2 | ||
|
|
baa62b286d | ||
|
|
908f63557c | ||
|
|
f88c4a6d4a | ||
|
|
0972f40a4e | ||
|
|
1f0486eba2 | ||
|
|
086183b563 | ||
|
|
df4ef1fbe5 | ||
|
|
727fa06c18 | ||
|
|
bee6eb288f | ||
|
|
3473aed27e | ||
|
|
a9144a2167 | ||
|
|
59822d6e42 | ||
|
|
ee84f74f8a | ||
|
|
18925decaf | ||
|
|
67146c33ef | ||
|
|
d5eb4d8d43 | ||
|
|
fc12ab84d9 | ||
|
|
0d088e085e | ||
|
|
2fe53ec5a8 | ||
|
|
ce0266f74e | ||
|
|
c21fa5b535 | ||
|
|
647d5efb09 | ||
|
|
bb89f679b6 | ||
|
|
3210ac29d0 | ||
|
|
e9c899ce41 | ||
|
|
0bd1bee1fa | ||
|
|
46326f9d16 | ||
|
|
ad18a40969 | ||
|
|
ae2ede8952 | ||
|
|
675060bcdf | ||
|
|
f8f388db6d | ||
|
|
2948f9360e | ||
|
|
df4ac8361f | ||
|
|
92a874e36d | ||
|
|
6d7907e844 | ||
|
|
d9788c4aa9 | ||
|
|
db0a5c44f6 | ||
|
|
c545ec16e7 | ||
|
|
961d110720 | ||
|
|
e8776fd336 | ||
|
|
7c08809501 | ||
|
|
7fd43c9529 | ||
|
|
33f8831e88 | ||
|
|
20b119c1ac | ||
|
|
6cd20491d2 | ||
|
|
cb8a50bd66 | ||
|
|
fddbb3cdfe | ||
|
|
01c179f762 | ||
|
|
e08fa35548 | ||
|
|
d96d65b8af | ||
|
|
54b96cc451 | ||
|
|
7d2a187865 | ||
|
|
900827be97 | ||
|
|
b9faa6486b | ||
|
|
3f2a04c881 | ||
|
|
ff618956b5 | ||
|
|
b5988f9a5f | ||
|
|
3ba09d07eb | ||
|
|
f998873fc5 | ||
|
|
33b54c08b5 | ||
|
|
34136ef617 | ||
|
|
17df5dd072 | ||
|
|
4b48a36ad3 | ||
|
|
2d3359a7e3 | ||
|
|
6d08b10820 | ||
|
|
78af1a3e6d | ||
|
|
16bd3e9fd5 | ||
|
|
6360c82898 | ||
|
|
67a8f7fbfe | ||
|
|
e17c637483 | ||
|
|
15013d6c5d | ||
|
|
8cb05c413a | ||
|
|
2098a2d3d2 | ||
|
|
6c1ee830a1 | ||
|
|
0658b7943e | ||
|
|
f1df9eeb2f | ||
|
|
edbbccf3ae | ||
|
|
a3973791fb | ||
|
|
cb5c0403c1 | ||
|
|
7bf924f7e9 | ||
|
|
c53dc3b15d | ||
|
|
b96bd33982 | ||
|
|
92c9001c9d | ||
|
|
157ab80be1 | ||
|
|
ac0f95b773 | ||
|
|
5f09e83ed8 | ||
|
|
27349ec953 | ||
|
|
2ce6e0a94a | ||
|
|
da65988926 | ||
|
|
b3b8e7b0cc | ||
|
|
c8f1756a2b | ||
|
|
f943701e13 | ||
|
|
7bf9addd13 | ||
|
|
93983c976b | ||
|
|
05a2fddf4a | ||
|
|
7f2be51a7e | ||
|
|
de01e13dda | ||
|
|
beca52715a | ||
|
|
61ed508a56 | ||
|
|
ce9d9fdf13 | ||
|
|
e32b585e97 | ||
|
|
6d2abfa863 | ||
|
|
d02724770d | ||
|
|
5fa37b5913 | ||
|
|
5155a4caf2 | ||
|
|
42381ed5cb | ||
|
|
b99f9e6888 | ||
|
|
e6c5d6ff7c | ||
|
|
4f9514885b | ||
|
|
52e695bd08 | ||
|
|
051f1b3e89 | ||
|
|
4392a881aa | ||
|
|
0949c9b334 | ||
|
|
30d04ce35a | ||
|
|
83efb0e3d4 | ||
|
|
08e968fd00 | ||
|
|
0d8e0d0352 | ||
|
|
1ab3a4b80a | ||
|
|
b6e60a412b | ||
|
|
d4cc727d52 | ||
|
|
45ebfb937e | ||
|
|
85325cda7f | ||
|
|
3fa7ff9858 | ||
|
|
b16fbafa1f | ||
|
|
ccb98553f1 | ||
|
|
d8060afe02 | ||
|
|
6502feb1a5 | ||
|
|
8cd067b6e9 | ||
|
|
d6eb3ae64e | ||
|
|
02d6b46448 | ||
|
|
df94db050b | ||
|
|
8d8e750982 | ||
|
|
6c7f1cad8b | ||
|
|
74c36ac31d | ||
|
|
385fb23b72 | ||
|
|
adc07a9624 | ||
|
|
335377e250 | ||
|
|
383c79c19a | ||
|
|
44dbb745b6 | ||
|
|
30a8934626 | ||
|
|
2d0afb29d3 | ||
|
|
86cadb8283 | ||
|
|
a494507dfe | ||
|
|
887f5dd487 | ||
|
|
8da357aea0 | ||
|
|
a304742828 | ||
|
|
a14af7c21c | ||
|
|
ff5106bf42 | ||
|
|
42788adb75 | ||
|
|
5774445dd7 | ||
|
|
dc7d1aabb8 | ||
|
|
b22eb5a49a | ||
|
|
d38b28d206 | ||
|
|
b0c663bacc | ||
|
|
5ff8ae32ec | ||
|
|
29e932252f | ||
|
|
a0e594a292 | ||
|
|
96f3861c90 | ||
|
|
77f3f99fbb | ||
|
|
e2b99b68e3 | ||
|
|
86addbc4dd |
257
.all-contributorsrc
Normal file
@@ -0,0 +1,257 @@
|
||||
{
|
||||
"files": [
|
||||
"README.md"
|
||||
],
|
||||
"imageSize": 100,
|
||||
"commit": false,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "sct",
|
||||
"name": "sct",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/234213?v=4",
|
||||
"profile": "https://sct.dev",
|
||||
"contributions": [
|
||||
"code",
|
||||
"design",
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "azoitos",
|
||||
"name": "Alex Zoitos",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/26529049?v=4",
|
||||
"profile": "https://github.com/azoitos",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "OwsleyJr",
|
||||
"name": "Brandon Cohen",
|
||||
"avatar_url": "https://avatars3.githubusercontent.com/u/8635678?v=4",
|
||||
"profile": "https://github.com/OwsleyJr",
|
||||
"contributions": [
|
||||
"code",
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Ahreluth",
|
||||
"name": "Ahreluth",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/75682440?v=4",
|
||||
"profile": "https://github.com/Ahreluth",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "KovalevArtem",
|
||||
"name": "KovalevArtem",
|
||||
"avatar_url": "https://avatars0.githubusercontent.com/u/36500228?v=4",
|
||||
"profile": "https://github.com/KovalevArtem",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "GiyomuWeb",
|
||||
"name": "GiyomuWeb",
|
||||
"avatar_url": "https://avatars0.githubusercontent.com/u/62489209?v=4",
|
||||
"profile": "https://github.com/GiyomuWeb",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "angrycuban13",
|
||||
"name": "Angry Cuban",
|
||||
"avatar_url": "https://avatars3.githubusercontent.com/u/39564898?v=4",
|
||||
"profile": "https://github.com/angrycuban13",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "jvennik",
|
||||
"name": "jvennik",
|
||||
"avatar_url": "https://avatars3.githubusercontent.com/u/6672637?v=4",
|
||||
"profile": "https://github.com/jvennik",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "darknessgp",
|
||||
"name": "darknessgp",
|
||||
"avatar_url": "https://avatars0.githubusercontent.com/u/1521243?v=4",
|
||||
"profile": "https://github.com/darknessgp",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "saltydk",
|
||||
"name": "salty",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/6587950?v=4",
|
||||
"profile": "https://github.com/saltydk",
|
||||
"contributions": [
|
||||
"infra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Shutruk",
|
||||
"name": "Shutruk",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/9198633?v=4",
|
||||
"profile": "https://github.com/Shutruk",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "krystiancharubin",
|
||||
"name": "Krystian Charubin",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/17775600?v=4",
|
||||
"profile": "https://github.com/krystiancharubin",
|
||||
"contributions": [
|
||||
"design"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "kieron",
|
||||
"name": "Kieron Boswell",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/8655212?v=4",
|
||||
"profile": "https://github.com/kieron",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "samwiseg0",
|
||||
"name": "samwiseg0",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/2241731?v=4",
|
||||
"profile": "https://github.com/samwiseg0",
|
||||
"contributions": [
|
||||
"question",
|
||||
"infra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "ecelebi29",
|
||||
"name": "ecelebi29",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/8337120?v=4",
|
||||
"profile": "https://github.com/ecelebi29",
|
||||
"contributions": [
|
||||
"code",
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "mmozeiko",
|
||||
"name": "Mārtiņš Možeiko",
|
||||
"avatar_url": "https://avatars3.githubusercontent.com/u/1665010?v=4",
|
||||
"profile": "https://github.com/mmozeiko",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "mazzetta86",
|
||||
"name": "mazzetta86",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/45591560?v=4",
|
||||
"profile": "https://github.com/mazzetta86",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Panzer1119",
|
||||
"name": "Paul Hagedorn",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/23016343?v=4",
|
||||
"profile": "https://github.com/Panzer1119",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Shagon94",
|
||||
"name": "Shagon94",
|
||||
"avatar_url": "https://avatars3.githubusercontent.com/u/9140783?v=4",
|
||||
"profile": "https://github.com/Shagon94",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "sebstrgg",
|
||||
"name": "sebstrgg",
|
||||
"avatar_url": "https://avatars3.githubusercontent.com/u/27026694?v=4",
|
||||
"profile": "https://github.com/sebstrgg",
|
||||
"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>",
|
||||
"contributorsPerLine": 7,
|
||||
"projectName": "overseerr",
|
||||
"projectOwner": "sct",
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"skipCi": true
|
||||
}
|
||||
@@ -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/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
github: [sct]
|
||||
patreon: overseerr
|
||||
45
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: 'awaiting-triage, type:bug'
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**What version of Overseerr are you running?**
|
||||
Please fill in the version you are currently running.
|
||||
|
||||
You can find it under: Settings -> About -> Version
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Support via Discord
|
||||
url: https://discord.gg/PkCWJSeCk7
|
||||
about: Chat with users and devs on support and setup related topics.
|
||||
- name: Support via GitHub Discussions
|
||||
url: https://github.com/sct/overseerr/discussions
|
||||
about: Ask questions and discuss with other community members
|
||||
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: 'awaiting-triage, type:enhancement'
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
13
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
#### Description
|
||||
|
||||
#### Screenshot (if UI related)
|
||||
|
||||
#### Todos
|
||||
|
||||
- [ ] Sucessfully builds `yarn build`
|
||||
- [ ] Translation Keys `yarn i18n:extract`
|
||||
- [ ] Database migration created (if required)
|
||||
|
||||
#### Issues Fixed or Closed by this PR
|
||||
|
||||
- Fixes #XXXX
|
||||
38
.github/lock.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# Configuration for Lock Threads - https://github.com/dessant/lock-threads-app
|
||||
|
||||
# Number of days of inactivity before a closed issue or pull request is locked
|
||||
daysUntilLock: 365
|
||||
|
||||
# Skip issues and pull requests created before a given timestamp. Timestamp must
|
||||
# follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable
|
||||
skipCreatedBefore: false
|
||||
|
||||
# Issues and pull requests with these labels will be ignored. Set to `[]` to disable
|
||||
exemptLabels:
|
||||
- dependencies
|
||||
|
||||
# Label to add before locking, such as `outdated`. Set to `false` to disable
|
||||
lockLabel: false
|
||||
|
||||
# Comment to post before locking. Set to `false` to disable
|
||||
lockComment: >
|
||||
This thread has been automatically locked since there has not been
|
||||
any recent activity after it was closed. Please open a new issue for
|
||||
related bugs.
|
||||
|
||||
# Assign `resolved` as the reason for locking. Set to `false` to disable
|
||||
setLockReason: true
|
||||
# Limit to only `issues` or `pulls`
|
||||
# only: issues
|
||||
|
||||
# Optionally, specify configuration settings just for `issues` or `pulls`
|
||||
# issues:
|
||||
# exemptLabels:
|
||||
# - help-wanted
|
||||
# lockLabel: outdated
|
||||
|
||||
# pulls:
|
||||
# daysUntilLock: 30
|
||||
|
||||
# Repository to extend settings from
|
||||
# _extends: repo
|
||||
18
.github/stale.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 60
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- pinned
|
||||
- security
|
||||
- dependencies
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: wontfix
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
35
.github/workflows/ci.yml
vendored
@@ -26,15 +26,36 @@ jobs:
|
||||
build_and_push:
|
||||
name: Build and push Docker image to Docker Hub
|
||||
needs: test
|
||||
if: github.ref == 'refs/heads/develop'
|
||||
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, 'skip ci')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: Build and push to Docker Hub
|
||||
uses: docker/build-push-action@v1
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
repository: sctx/overseerr
|
||||
tags: develop
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.CR_PAT }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
build-args: |
|
||||
COMMIT_TAG=${{ github.sha }}
|
||||
tags: |
|
||||
sctx/overseerr:develop
|
||||
sctx/overseerr:${{ github.sha }}
|
||||
ghcr.io/sct/overseerr:develop
|
||||
ghcr.io/sct/overseerr:${{ github.sha }}
|
||||
|
||||
19
.github/workflows/invalid_template.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: 'Invalid Template'
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled, unlabeled, reopened]
|
||||
|
||||
jobs:
|
||||
support:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/support-requests@v2
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
support-label: 'invalid:template-incomplete'
|
||||
issue-comment: >
|
||||
:wave: @{issue-author}, please edit your issue and follow the template provided.
|
||||
close-issue: false
|
||||
lock-issue: false
|
||||
issue-lock-reason: 'resolved'
|
||||
2
.github/workflows/release.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
run: yarn lint
|
||||
- name: build
|
||||
run: yarn build
|
||||
semnatic-release:
|
||||
semantic-release:
|
||||
name: Tag and release latest version
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
25
.github/workflows/support.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: 'Support requests'
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled, unlabeled, reopened]
|
||||
|
||||
jobs:
|
||||
support:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/support-requests@v2
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
support-label: 'support'
|
||||
issue-comment: >
|
||||
:wave: @{issue-author}, we use the issue tracker exclusively
|
||||
for bug reports and feature requests. However, this issue appears
|
||||
to be a support request. Please use our support channels
|
||||
to get help with Overseerr.
|
||||
|
||||
- [Discord](https://discord.gg/PkCWJSeCk7)
|
||||
- [GitHub Discussions](https://github.com/sct/overseerr/discussions)
|
||||
close-issue: true
|
||||
lock-issue: false
|
||||
issue-lock-reason: 'off-topic'
|
||||
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
|
||||
|
||||
2
.vscode/settings.json
vendored
@@ -6,7 +6,7 @@
|
||||
"typescript",
|
||||
"typescriptreact"
|
||||
],
|
||||
"typescript.tsdk": "./app/node_modules/typescript/lib",
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"sqltools.connections": [
|
||||
{
|
||||
"previewLimit": 50,
|
||||
|
||||
354
CHANGELOG.md
@@ -1,3 +1,357 @@
|
||||
# [1.16.0](https://github.com/sct/overseerr/compare/v1.15.0...v1.16.0) (2021-01-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** adjust titlecard badge styling ([effc809](https://github.com/sct/overseerr/commit/effc80977a4ed732092254248f82363e52233171))
|
||||
* **frontend:** apply same titlecard hover effect to personcard ([67f2b57](https://github.com/sct/overseerr/commit/67f2b57f00216ded3b34965629d6fdd2f16bc25f))
|
||||
* **frontend:** only animate titlecard when showDetail is true ([0ab4c3c](https://github.com/sct/overseerr/commit/0ab4c3c36fe2c1ded142b6931111516f7f990a41))
|
||||
* **frontend:** use hardware acceleration for titlecard scale ([88810bf](https://github.com/sct/overseerr/commit/88810bf0a4ef74299f6541b60fa91cea3610f99c))
|
||||
* **plex-sync:** do not run plex sync if no admin exists ([493d82b](https://github.com/sct/overseerr/commit/493d82b6b066d77609cf66e005fd1f1472b8e011))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **lang:** translations update from Weblate ([#495](https://github.com/sct/overseerr/issues/495)) ([b04eda6](https://github.com/sct/overseerr/commit/b04eda6c8a3bfcaa2a14b8a29612fdf690c9fba0))
|
||||
* **lang:** Translations update from Weblate ([#580](https://github.com/sct/overseerr/issues/580)) ([2bfe0f2](https://github.com/sct/overseerr/commit/2bfe0f2bf66956763ab26d5c54f26e6c456f59f7))
|
||||
* **notifications:** add pushover integration ([#574](https://github.com/sct/overseerr/issues/574)) ([ee5d018](https://github.com/sct/overseerr/commit/ee5d0181fc9a673b27aefd1d09b0a78c3d2e4f55))
|
||||
|
||||
# [1.15.0](https://github.com/sct/overseerr/compare/v1.14.1...v1.15.0) (2021-01-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** return 202 when same seasons are requested again ([5c84702](https://github.com/sct/overseerr/commit/5c847026aad79fcac4d020786ded9f867696c226))
|
||||
* **build:** fixes build to include commit tag for app build step ([289864a](https://github.com/sct/overseerr/commit/289864af1a995ce04834bf8a220cc238e1954d19))
|
||||
* **docs:** fix typo in build instructions ([#503](https://github.com/sct/overseerr/issues/503)) ([2b27a71](https://github.com/sct/overseerr/commit/2b27a715b07c27200ba1e5e9623629a34389276d))
|
||||
* **frontend:** add i18n for request text on titlecard ([a524b9c](https://github.com/sct/overseerr/commit/a524b9c4c8968f6823d33eb270dc26069fe4a725))
|
||||
* **frontend:** add localized strings for status checker ([2dcda39](https://github.com/sct/overseerr/commit/2dcda39d40d820419e098bd6f1101eb820e5b42d))
|
||||
* **frontend:** center text in movie auto-approve modal on small screens ([#510](https://github.com/sct/overseerr/issues/510)) ([1438b08](https://github.com/sct/overseerr/commit/1438b08cf0b358d79c6688c64be99f1718ec2d23)), closes [#507](https://github.com/sct/overseerr/issues/507)
|
||||
* **frontend:** change titlecard to only have a request button ([b5a3a7a](https://github.com/sct/overseerr/commit/b5a3a7a89fcaf86dd794dc419711677b53646577))
|
||||
* **frontend:** combine duplicate credits on a persons detail page ([d188f6f](https://github.com/sct/overseerr/commit/d188f6ffadff1564c47d5f33138e35498bed29fd)), closes [#504](https://github.com/sct/overseerr/issues/504)
|
||||
* **frontend:** disable pointer-events on titlecard badges ([ce06879](https://github.com/sct/overseerr/commit/ce0687922a94588b3492e8ddf2e84f54dd1a0d4e))
|
||||
* **frontend:** fix count of requests in request list ([f124d73](https://github.com/sct/overseerr/commit/f124d732a2911abdccb5abc11471efe61cc20f7a))
|
||||
* **frontend:** fix sliders overflowing on firefox ([67ac9e0](https://github.com/sct/overseerr/commit/67ac9e075f0ca1cfe7e4766d9168815d7ab600fa)), closes [#566](https://github.com/sct/overseerr/issues/566)
|
||||
* **frontend:** full season request modal fits on a smaller mobile UI ([#535](https://github.com/sct/overseerr/issues/535)) ([12db7a0](https://github.com/sct/overseerr/commit/12db7a065ad566b47d46de4b949343290894f153))
|
||||
* **frontend:** handle currentLibrary possibly being null on first manual sync ([93b57a7](https://github.com/sct/overseerr/commit/93b57a76f10a823615ca11ff59f523b67aa30fad))
|
||||
* **frontend:** increase titlecard status badge size on larger screens ([ba106c4](https://github.com/sct/overseerr/commit/ba106c447d76db2f9ac70a60c5b38cc60ab554fe))
|
||||
* **frontend:** search clear button now correctly triggers routing ([343f466](https://github.com/sct/overseerr/commit/343f466788abc308b91a414ef61bba816ac8875c))
|
||||
* **frontend:** set locale cookie expiration to be much longer ([fae4818](https://github.com/sct/overseerr/commit/fae481895736eab81d52eb93788beb00669fb355))
|
||||
* **frontend:** show movie/series badges always ([8cbf39a](https://github.com/sct/overseerr/commit/8cbf39a9d12eaee7720fa4721c350c1ef9dee856))
|
||||
* **frontend:** update login/setup images ([058fb65](https://github.com/sct/overseerr/commit/058fb65495baa08a0bd4c9e0aef320c6fc7d017b))
|
||||
* **holiday:** remove special holiday slider ([8c09033](https://github.com/sct/overseerr/commit/8c0903393cf2cb2a929ba70a8ab6ddcc4cba0574))
|
||||
* correctly deal with tmdb id duplicates between movies/series ([721ed9a](https://github.com/sct/overseerr/commit/721ed9a93087a57ae749388bddcacf26022e3df6)), closes [#526](https://github.com/sct/overseerr/issues/526)
|
||||
* use new commit tag file for app version as well ([d00e470](https://github.com/sct/overseerr/commit/d00e470b55327489b49d770144b7cfdb24045be6))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **email:** add sendername to email notification ([#506](https://github.com/sct/overseerr/issues/506)) ([0185bb1](https://github.com/sct/overseerr/commit/0185bb1a7084c1faeb61fb1c63e34e26732711c8))
|
||||
* **frontend:** add clear-field-icon to search field ([#498](https://github.com/sct/overseerr/issues/498)) ([7434a26](https://github.com/sct/overseerr/commit/7434a26f76b5e9f74918f3e1a34443d20ecfcbe4))
|
||||
* **frontend:** add documentation link to about page ([c034496](https://github.com/sct/overseerr/commit/c034496f557a031aed35cd28dc7221d8cdf36643))
|
||||
* **frontend:** add telegram integration ([#491](https://github.com/sct/overseerr/issues/491)) ([c8d4d67](https://github.com/sct/overseerr/commit/c8d4d674f412082ad9e9da09abd79660365cf728))
|
||||
* **frontend:** filter/sorting for request list ([5add44c](https://github.com/sct/overseerr/commit/5add44cfb0379aa6fed7c3b867230292feacc684)), closes [#431](https://github.com/sct/overseerr/issues/431)
|
||||
* **notifications:** control notifcation types per agent ([8af6a1f](https://github.com/sct/overseerr/commit/8af6a1f566769c583af7dd9e18d162717835b7cc)), closes [#513](https://github.com/sct/overseerr/issues/513)
|
||||
* status checker to prompt users to reload their frontend when app version changes ([75a4264](https://github.com/sct/overseerr/commit/75a426437a4182e21da13684066966dd5bf8fc5e))
|
||||
|
||||
## [1.14.1](https://github.com/sct/overseerr/compare/v1.14.0...v1.14.1) (2021-01-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **holiday:** remove special holiday slider ([22f2037](https://github.com/sct/overseerr/commit/22f2037ea6c5a0ba2ffa4d69f2b7cf42bdcf8575))
|
||||
|
||||
# [1.14.0](https://github.com/sct/overseerr/compare/v1.13.0...v1.14.0) (2020-12-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** add margin to ButtonWithDropdown component on movie/tv details page ([06fc98b](https://github.com/sct/overseerr/commit/06fc98b6b958221fa180f57f702c348f15b31f1c))
|
||||
* **frontend:** correctly position title card hover section ([#486](https://github.com/sct/overseerr/issues/486)) ([4b7af86](https://github.com/sct/overseerr/commit/4b7af86111a0300e1a137f23fa4ad1639fa55feb))
|
||||
* **frontend:** fix missing styles for alert component ([de3d288](https://github.com/sct/overseerr/commit/de3d288949b60d3a3af889d69a62bea2bc799ed7))
|
||||
* **frontend:** fix mobile dropdown in notifications settings ([6353cda](https://github.com/sct/overseerr/commit/6353cda5825f442dd539886c7b9ba437edf27ac4))
|
||||
* **frontend:** fix scaling titlecard content position ([bd94740](https://github.com/sct/overseerr/commit/bd947409e6e8ff313011b77adc76ccd5f9112c78))
|
||||
* **frontend:** improve flex header on movie/tv details page ([d7b1c28](https://github.com/sct/overseerr/commit/d7b1c2840690c144ebf29a360defcbd6fdb21354))
|
||||
* **frontend:** invalid dom-nesting title card fix ([#482](https://github.com/sct/overseerr/issues/482)) ([f2ebba7](https://github.com/sct/overseerr/commit/f2ebba7b1df775d33d2af6abc3ee2c9de5f2e57a)), closes [#476](https://github.com/sct/overseerr/issues/476)
|
||||
* **frontend:** remove vote permission for now ([5d06a34](https://github.com/sct/overseerr/commit/5d06a347311bd10c05d8f58068ca7104e265dcca))
|
||||
* **frontend:** sort person detail credits by tmdb votes ([17518db](https://github.com/sct/overseerr/commit/17518dbe7f545100770a892d03d1f8508adc3650))
|
||||
* **frontend:** status badge Unavailable renamed to Requested ([ed94a0f](https://github.com/sct/overseerr/commit/ed94a0f335c59de526dd812aea7616313fe002fd)), closes [#374](https://github.com/sct/overseerr/issues/374)
|
||||
* **frontend:** update titlecard status badge to new requested colors ([8f292d5](https://github.com/sct/overseerr/commit/8f292d538b937ea133175089979ef02599f6fef4))
|
||||
* **logs:** rotate logs on a daily basis instead of incrementing log filename ([395cbb2](https://github.com/sct/overseerr/commit/395cbb2be6c62f1d7573593e49a93615eaf22853))
|
||||
* improve apple-touch-icon and android app icons ([329a814](https://github.com/sct/overseerr/commit/329a814a8fb791122266c0b04b05848c71d68ba1))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **lang:** translations update from Weblate ([#479](https://github.com/sct/overseerr/issues/479)) ([c8c74b0](https://github.com/sct/overseerr/commit/c8c74b0ae54fcc524aa8b2edf5a5c5e5db6c1638))
|
||||
* **notifications:** add slack notification agent ([1163e81](https://github.com/sct/overseerr/commit/1163e81adc7da1e8334155ebee5b4672a22143db)), closes [#365](https://github.com/sct/overseerr/issues/365)
|
||||
* add collections ([#484](https://github.com/sct/overseerr/issues/484)) ([a333a09](https://github.com/sct/overseerr/commit/a333a095820ce3f10857026ba4770a2fffeed7cb)), closes [#418](https://github.com/sct/overseerr/issues/418)
|
||||
* add separate auto approve permissions for Movies/Series ([4809257](https://github.com/sct/overseerr/commit/480925781691de456abc427fbbba161be11a3a8a)), closes [#268](https://github.com/sct/overseerr/issues/268)
|
||||
* simple failed request handling ([#474](https://github.com/sct/overseerr/issues/474)) ([02969d5](https://github.com/sct/overseerr/commit/02969d5426245062a2f53475d83c4a8639632c9d))
|
||||
* YouTube Movie/TV Trailers ([#454](https://github.com/sct/overseerr/issues/454)) ([e88dc83](https://github.com/sct/overseerr/commit/e88dc83aeba0475e3ad421d5ab130cea4fc9a806))
|
||||
|
||||
# [1.13.0](https://github.com/sct/overseerr/compare/v1.12.1...v1.13.0) (2020-12-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** correctly return firstAirDate for series in search endpoints ([32b4c99](https://github.com/sct/overseerr/commit/32b4c99950659d9e1da2ffa93c22383c54d0d904)), closes [#462](https://github.com/sct/overseerr/issues/462)
|
||||
* **email:** correctly log errors when emails fail to send ([0980fa5](https://github.com/sct/overseerr/commit/0980fa54f9fc3bdfae6c57fa5a20ce3b2a88a677))
|
||||
* **frontend:** added new Radarr v3 logo ([#471](https://github.com/sct/overseerr/issues/471)) ([3bbc716](https://github.com/sct/overseerr/commit/3bbc716434dc04bfe6b55de9898eb2c0ecb03baa))
|
||||
* **frontend:** approve and decline button (in manage panel) will now fit on mobile ([#441](https://github.com/sct/overseerr/issues/441)) ([66ef72d](https://github.com/sct/overseerr/commit/66ef72dd42912d83ea8f86aabb75fbee547f8de9))
|
||||
* **frontend:** filter out undefined backdrop paths for person details page ([2e0e4d5](https://github.com/sct/overseerr/commit/2e0e4d5129ed4912415f61eb8d1da41e88ddcaff))
|
||||
* **frontend:** show backdrops instead of posters for new person detail design ([9f5f920](https://github.com/sct/overseerr/commit/9f5f920c23007363aa7f53ebef0b61236d4f53ea))
|
||||
* clarify full sync runs every 24 hours ([0c8a180](https://github.com/sct/overseerr/commit/0c8a180189b2610bab2fa977d458743d8a60343e))
|
||||
* **plex-sync:** match correct tmdb format for movies ([4205e32](https://github.com/sct/overseerr/commit/4205e32ae71bc18c07209f1c82e6af1cb5f01335))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **email:** option to allow self signed certificates ([6898357](https://github.com/sct/overseerr/commit/6898357b13a6aa53a55709ea95819c2b3df6784c))
|
||||
* **frontend:** adjust person details design and add improved truncate ([1fb7ea7](https://github.com/sct/overseerr/commit/1fb7ea72589d2908ae80a2a688881d4eb3c050e5))
|
||||
* **frontend:** first air date added to TV details page ([#470](https://github.com/sct/overseerr/issues/470)) ([a7db01f](https://github.com/sct/overseerr/commit/a7db01fba483ca633a6eb9d39eb085ab9939d4d2))
|
||||
* **lang:** translations update from Weblate ([#410](https://github.com/sct/overseerr/issues/410)) ([941fe19](https://github.com/sct/overseerr/commit/941fe1990454439cf05b48ef92bd3493432f8ed8))
|
||||
* **logs:** rotate log files if they reach 20MB in size ([22002ab](https://github.com/sct/overseerr/commit/22002ab4c76aace2bb202ac58da605b7a6f75d6d)), closes [#438](https://github.com/sct/overseerr/issues/438)
|
||||
* **notifications:** include direct links to media in notifications ([659fa50](https://github.com/sct/overseerr/commit/659fa505f0db32262ad0041cddb4daea893e6d65)), closes [#437](https://github.com/sct/overseerr/issues/437)
|
||||
* **plex-sync:** add support for hama guid's ([ffe9e19](https://github.com/sct/overseerr/commit/ffe9e19c3b99de6af1185900e292da641ff44320)), closes [#453](https://github.com/sct/overseerr/issues/453)
|
||||
|
||||
## [1.12.1](https://github.com/sct/overseerr/compare/v1.12.0...v1.12.1) (2020-12-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **migration:** fixes issue migrating away from the unique imdbId constraint ([69fd7a5](https://github.com/sct/overseerr/commit/69fd7a5511215674a5c22ba48627f221da900229))
|
||||
|
||||
# [1.12.0](https://github.com/sct/overseerr/compare/v1.11.0...v1.12.0) (2020-12-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** fix cross-imported type crashing build ([f35dae5](https://github.com/sct/overseerr/commit/f35dae56a583a5545375318fa5be994ae1f2557f))
|
||||
* **api:** prevent checking first admin account for plex server access ([22006e9](https://github.com/sct/overseerr/commit/22006e9dbde82609440f89bde9a40887b4742682))
|
||||
* **frontend:** add name, short_name and start_url to manifest ([#424](https://github.com/sct/overseerr/issues/424)) ([c6836e0](https://github.com/sct/overseerr/commit/c6836e02c810e8adb12c3a4b110f9604cf5b7b81))
|
||||
* **frontend:** adjust person card layout to deal with overflowing content ([4891298](https://github.com/sct/overseerr/commit/48912988915ae40606a900a6f1dd23fc25ed567f)), closes [#416](https://github.com/sct/overseerr/issues/416)
|
||||
* **frontend:** allow more special characters in search input ([5deb64a](https://github.com/sct/overseerr/commit/5deb64a87fd70e97da27a025ad11fb8ace0e0b57)), closes [#430](https://github.com/sct/overseerr/issues/430)
|
||||
* **logs:** improve logging when adding to sonarr/radarr ([4b50522](https://github.com/sct/overseerr/commit/4b505223b881a750007e3fbc7d4bcb9677d4d412))
|
||||
* only run migrations in production ([ab9cef3](https://github.com/sct/overseerr/commit/ab9cef3624b5db1ec03507553a69d33b87857e29))
|
||||
* **notifications:** always update the media table when seasons become available ([0916b58](https://github.com/sct/overseerr/commit/0916b58594a00db98c6701fdcaee4f3c3e08904e))
|
||||
* **plex-sync:** fixes processing movies using TMDB agent ([764db94](https://github.com/sct/overseerr/commit/764db94f1bd7866309684d5bd56033b21cbc2e0c)), closes [#363](https://github.com/sct/overseerr/issues/363)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** add crew related movies/shows to person details page ([12127a7](https://github.com/sct/overseerr/commit/12127a77633f0e92ae88cbafd49581296f559c33))
|
||||
* **frontend:** add full crew page for movies/shows ([604ba2a](https://github.com/sct/overseerr/commit/604ba2a92f1d59489e7fc6dfc011347f8595c123))
|
||||
* default user permissions added to settings ([e7ee85c](https://github.com/sct/overseerr/commit/e7ee85c29b5d25c6bff58717eae5e62de4dcef0c)), closes [#388](https://github.com/sct/overseerr/issues/388)
|
||||
* import users from plex ([#428](https://github.com/sct/overseerr/issues/428)) ([7e8f361](https://github.com/sct/overseerr/commit/7e8f361af711001cfc4dcc06a384b76f9846f90f)), closes [#281](https://github.com/sct/overseerr/issues/281)
|
||||
* **frontend:** add prioritized crew under overview ([6753d9d](https://github.com/sct/overseerr/commit/6753d9daaafb18672f14fd86f2c1675dcec39b13)), closes [#406](https://github.com/sct/overseerr/issues/406)
|
||||
* **notifications:** added ability to send test notifications ([44a3054](https://github.com/sct/overseerr/commit/44a305426f3e9829c167a4a73095d0d248641f47)), closes [#309](https://github.com/sct/overseerr/issues/309)
|
||||
|
||||
|
||||
### Reverts
|
||||
|
||||
* **deps:** revert react-use-clipboard to 1.0.2 ([7083ddf](https://github.com/sct/overseerr/commit/7083ddf18121716e3442acab3506c395fdc351ac))
|
||||
|
||||
# [1.11.0](https://github.com/sct/overseerr/compare/v1.10.0...v1.11.0) (2020-12-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** add language picker to setup/login ([ff2ab29](https://github.com/sct/overseerr/commit/ff2ab29491a80c421525b9a394d6fbbf54914dc2))
|
||||
* **frontend:** add support overseerr block to about page ([c128898](https://github.com/sct/overseerr/commit/c128898206d6cbb482de4d8dca53f70b87e4911a))
|
||||
* **frontend:** releases added to about page ([b7f5739](https://github.com/sct/overseerr/commit/b7f573903500cc8a62e39afd787bc1da8c09d88b)), closes [#303](https://github.com/sct/overseerr/issues/303)
|
||||
* **lang:** add support for Italian, Portuguese (Brazil) and Serbian ([108dfc4](https://github.com/sct/overseerr/commit/108dfc4afd31388cb6c9e07deccd168ade8b1574))
|
||||
* **lang:** add support for swedish language ([c9fe6cb](https://github.com/sct/overseerr/commit/c9fe6cb0b7ea984d8e4e1cb3f284935c9da7cc2b))
|
||||
* **lang:** translations update from Weblate ([#400](https://github.com/sct/overseerr/issues/400)) ([1bd0e64](https://github.com/sct/overseerr/commit/1bd0e646e313ddf77ef331e818e03401fbf64a72))
|
||||
* **lang:** translations update from Weblate ([#403](https://github.com/sct/overseerr/issues/403)) ([3778ad8](https://github.com/sct/overseerr/commit/3778ad829c0897de178212b3bde4c0d3b5089161))
|
||||
|
||||
# [1.10.0](https://github.com/sct/overseerr/compare/v1.9.1...v1.10.0) (2020-12-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **email:** fix link to Overseerr in email templates ([816fec1](https://github.com/sct/overseerr/commit/816fec1a83a53edb3b65c3e5e7d0e6e1bd49726d)), closes [#392](https://github.com/sct/overseerr/issues/392)
|
||||
* **frontend:** adjust padding of search box so placeholder text fits on mobile ([3601d44](https://github.com/sct/overseerr/commit/3601d442db32d3f98f7b050365c11ea8ef9bc4ae)), closes [#393](https://github.com/sct/overseerr/issues/393)
|
||||
* **frontend:** changed request block for slideover on mobile UI ([#387](https://github.com/sct/overseerr/issues/387)) ([549567a](https://github.com/sct/overseerr/commit/549567a7e9db01933546d9970fc06f17218dfab1))
|
||||
* **frontend:** hide Request More button if all current seasons are available ([2a4dd52](https://github.com/sct/overseerr/commit/2a4dd52275007e48f946c3b9e29f1d78da57bdaa)), closes [#343](https://github.com/sct/overseerr/issues/343)
|
||||
* **frontend:** try not to render broken rottentomatoes data ([a0c5608](https://github.com/sct/overseerr/commit/a0c5608aa0b6c7a4294300589efa9a662163ce48))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **lang:** translations update from Weblate ([#391](https://github.com/sct/overseerr/issues/391)) ([5f71fb7](https://github.com/sct/overseerr/commit/5f71fb7ee280714275d2ac045c472fcdddd5a2ea))
|
||||
* add missing tzdata package to image ([53bede6](https://github.com/sct/overseerr/commit/53bede692d4f0e940dededa63015fe1908129914)), closes [#394](https://github.com/sct/overseerr/issues/394)
|
||||
* **frontend:** add external links to movie and tv detail pages ([a0024a0](https://github.com/sct/overseerr/commit/a0024a0cbe717d78f53413bb78644c829f143c4d))
|
||||
* **lang:** translations update from Weblate ([#380](https://github.com/sct/overseerr/issues/380)) ([8408e19](https://github.com/sct/overseerr/commit/8408e19568b2f239c57e11e2946c75f193d1c22e))
|
||||
|
||||
## [1.9.1](https://github.com/sct/overseerr/compare/v1.9.0...v1.9.1) (2020-12-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* change default internal port to 5055 ([#389](https://github.com/sct/overseerr/issues/389)) ([5e5ba40](https://github.com/sct/overseerr/commit/5e5ba4050563f07bff367d2fb31ed7e7fca4291e))
|
||||
|
||||
# [1.9.0](https://github.com/sct/overseerr/compare/v1.8.0...v1.9.0) (2020-12-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* api key regeneration ([6beac73](https://github.com/sct/overseerr/commit/6beac736efcf7b9102e02e43b75d91a9a158cd22))
|
||||
* **api:** add movie keyword search ([f88c4a6](https://github.com/sct/overseerr/commit/f88c4a6d4a49f8f3451ba6c85153677f33b7f5f6))
|
||||
* **frontend:** add studio/networks to movie/tv details ([4b6ad8a](https://github.com/sct/overseerr/commit/4b6ad8a3871957db4192b603abf38404250cea5d)), closes [#370](https://github.com/sct/overseerr/issues/370)
|
||||
* **frontend:** added user deletion to the user list ([727fa06](https://github.com/sct/overseerr/commit/727fa06c18febb2a97ca219cc6bf0277ff462acd)), closes [#348](https://github.com/sct/overseerr/issues/348)
|
||||
* **holiday:** special seasonal slider added to discover :) ([908f635](https://github.com/sct/overseerr/commit/908f63557ca03a1da8b16809ffa2c3acd782d94e))
|
||||
* allow to listen server on specific host interface ([#381](https://github.com/sct/overseerr/issues/381)) ([086183b](https://github.com/sct/overseerr/commit/086183b5636aa8d075d01fe59492c3eab0d1345b)), closes [#273](https://github.com/sct/overseerr/issues/273)
|
||||
* anime profile support ([#384](https://github.com/sct/overseerr/issues/384)) ([0972f40](https://github.com/sct/overseerr/commit/0972f40a4e1fb3b5f02b07ae46b997d71aab9bfb)), closes [#266](https://github.com/sct/overseerr/issues/266)
|
||||
|
||||
# [1.8.0](https://github.com/sct/overseerr/compare/v1.7.0...v1.8.0) (2020-12-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **lang:** translations update from Weblate ([#336](https://github.com/sct/overseerr/issues/336)) ([ee84f74](https://github.com/sct/overseerr/commit/ee84f74f8a3558875b41daa539f42d00b949898a))
|
||||
|
||||
# [1.7.0](https://github.com/sct/overseerr/compare/v1.6.0...v1.7.0) (2020-12-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **email:** do not pass auth object to transport if no auth data present ([d5eb4d8](https://github.com/sct/overseerr/commit/d5eb4d8d438a159266b2de66b6bcdd9440a0c8ef)), closes [#312](https://github.com/sct/overseerr/issues/312)
|
||||
* **frontend:** add http/https prefix to hostname fields for plex/radarr/sonarr ([ce0266f](https://github.com/sct/overseerr/commit/ce0266f74ea3979b291ff962271a928682892788)), closes [#357](https://github.com/sct/overseerr/issues/357)
|
||||
* **frontend:** clarify that radarr/sonnarr servers must be tested before profiles/folders appear ([fc12ab8](https://github.com/sct/overseerr/commit/fc12ab84d9482eb3a11f117f8cab6fd48a9401cd)), closes [#326](https://github.com/sct/overseerr/issues/326) [#328](https://github.com/sct/overseerr/issues/328)
|
||||
* **frontend:** correctly show an unauthorized error when a user fails to login ([18925de](https://github.com/sct/overseerr/commit/18925decafdac518f52a354c594cc378d2529022)), closes [#322](https://github.com/sct/overseerr/issues/322)
|
||||
* **frontend:** fix tv shows failing to open when firstAirDate is undefined ([c21fa5b](https://github.com/sct/overseerr/commit/c21fa5b5350abdd8e03c077fde7246fa398e176e)), closes [#347](https://github.com/sct/overseerr/issues/347)
|
||||
* **frontend:** make minimum availability required for Radarr servers ([2fe53ec](https://github.com/sct/overseerr/commit/2fe53ec5a8534e75c7d0cef31a8b46065111e0a7)), closes [#345](https://github.com/sct/overseerr/issues/345)
|
||||
* **plex-sync:** bundle duplicate ratingKeys to speed up recently added sync ([67146c3](https://github.com/sct/overseerr/commit/67146c33ef7f28d520ba2c50b32673d43f4525c8)), closes [#360](https://github.com/sct/overseerr/issues/360)
|
||||
* **sonarr.ts, mediarequest.ts:** add missing seasonFolder option ([#358](https://github.com/sct/overseerr/issues/358)) ([e9c899c](https://github.com/sct/overseerr/commit/e9c899ce419d149dde2ad9a0f7d5a2f2545b3ebf))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** show alert when there are no default radarr/sonarr servers ([0d088e0](https://github.com/sct/overseerr/commit/0d088e085e68d39455fda21d1fd08ebcaef2c06b)), closes [#344](https://github.com/sct/overseerr/issues/344)
|
||||
|
||||
# [1.6.0](https://github.com/sct/overseerr/compare/v1.5.0...v1.6.0) (2020-12-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** accept the api key to perform actions on the api with X-API-Key header ([33f8831](https://github.com/sct/overseerr/commit/33f8831e880dc7fd3f69d951246cada5c6c0ffe7))
|
||||
* **api:** filter out libraries that do not have any metadata agent or are not movie/show ([01c179f](https://github.com/sct/overseerr/commit/01c179f762e686a1e5a3d4dab3a5bea53425b575))
|
||||
* **api:** only run recently added sync on enabled libraries ([e08fa35](https://github.com/sct/overseerr/commit/e08fa35548bb8644afa8df3124e6f9cc3a2c8f4a)), closes [#259](https://github.com/sct/overseerr/issues/259)
|
||||
* **api:** set plex libraries to disabled if the name changes ([675060b](https://github.com/sct/overseerr/commit/675060bcdf23acbfd4de2900a65f95e74f4966a5)), closes [#324](https://github.com/sct/overseerr/issues/324)
|
||||
* **frontend:** adds a tip to plex setup to clarify that syncing runs in the background ([df4ac83](https://github.com/sct/overseerr/commit/df4ac8361f82971ee845f3be217408a9123a0bf3)), closes [#325](https://github.com/sct/overseerr/issues/325)
|
||||
* **frontend:** aligned movie and tv details ([#331](https://github.com/sct/overseerr/issues/331)) ([db0a5c4](https://github.com/sct/overseerr/commit/db0a5c44f678e76eee7f5582381016306d1f46a2))
|
||||
* **frontend:** close sidebar when clicking outside ([#333](https://github.com/sct/overseerr/issues/333)) ([6d7907e](https://github.com/sct/overseerr/commit/6d7907e844a909993d185759d660632f55aeaa35))
|
||||
* spelling mistake on the word 'requested' fixed ([#319](https://github.com/sct/overseerr/issues/319)) ([961d110](https://github.com/sct/overseerr/commit/961d1107208069a6fc820a1ba97ffda7336677cb))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add version to startup logs ([2948f93](https://github.com/sct/overseerr/commit/2948f9360eb484d1d6c0740a840135ca97e7240a))
|
||||
* **frontend:** temporary logs page to clear up confusion about it 404ing ([d9788c4](https://github.com/sct/overseerr/commit/d9788c4aa9f87e2eda3f7e3f1adc985f16039552)), closes [#272](https://github.com/sct/overseerr/issues/272)
|
||||
* **lang:** add support for Spanish language ([6cd2049](https://github.com/sct/overseerr/commit/6cd20491d2a0ceb995c4744eeb92a6e2f57a4893))
|
||||
* **lang:** Translations update from Weblate ([#291](https://github.com/sct/overseerr/issues/291)) ([fddbb3c](https://github.com/sct/overseerr/commit/fddbb3cdfe3d50b2835c248556139c769dc2b805))
|
||||
|
||||
# [1.5.0](https://github.com/sct/overseerr/compare/v1.4.0...v1.5.0) (2020-12-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** require package.json directly so typescript doesnt compile it into dist folder ([b9faa64](https://github.com/sct/overseerr/commit/b9faa6486b35aa865019aa8af9d307531054bc1d))
|
||||
* **frontend:** add validation for Radarr/Sonarr server name ([b5988f9](https://github.com/sct/overseerr/commit/b5988f9a5ff274e97f208c2726abe76c22c858ee))
|
||||
* **frontend:** only show alpha notice to admins ([ff61895](https://github.com/sct/overseerr/commit/ff618956b5d9cf933d867ea979b612c3d8a6f30b))
|
||||
* add support for ssl when connecting to plex ([3ba09d0](https://github.com/sct/overseerr/commit/3ba09d07eb0367c41603cd55e7ff41c66fb641c4)), closes [#275](https://github.com/sct/overseerr/issues/275)
|
||||
* **services:** improve logging for when Radarr movie already exists ([#285](https://github.com/sct/overseerr/issues/285)) ([f998873](https://github.com/sct/overseerr/commit/f998873fc5669a547901f2733c9c785d744d27ca)), closes [#260](https://github.com/sct/overseerr/issues/260)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **lang:** add i18n strings for new about page ([900827b](https://github.com/sct/overseerr/commit/900827be97845688e4bea72a8c5d9611a3e9d069))
|
||||
* about page initial version ([3f2a04c](https://github.com/sct/overseerr/commit/3f2a04c881bf06b73a952181fa463af84454b0dd))
|
||||
|
||||
# [1.4.0](https://github.com/sct/overseerr/compare/v1.3.2...v1.4.0) (2020-12-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* changing parameter name to use correct 'port' [#276](https://github.com/sct/overseerr/issues/276) ([#277](https://github.com/sct/overseerr/issues/277)) ([6d08b10](https://github.com/sct/overseerr/commit/6d08b108200177ca3068c852e60a0df75ce2232a))
|
||||
* **services:** include radarr/sonarr baseUrl when adding media ([78af1a3](https://github.com/sct/overseerr/commit/78af1a3e6d00a5645a05e7bf3cf56a59439b6cc9))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **lang:** Translations update from Weblate ([#240](https://github.com/sct/overseerr/issues/240)) ([e17c637](https://github.com/sct/overseerr/commit/e17c63748362b6a480693e003ef5eec614dcec43))
|
||||
|
||||
## [1.3.2](https://github.com/sct/overseerr/compare/v1.3.1...v1.3.2) (2020-12-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** convert plex port to a number before posting to the api ([8cb05c4](https://github.com/sct/overseerr/commit/8cb05c413a15a4b74e37ece5e24367d115995b32))
|
||||
* **frontend:** converts email smtp port to a number before posting to the api ([2098a2d](https://github.com/sct/overseerr/commit/2098a2d3d2981fd2ae54392aec3ef81327f2858e)), closes [#251](https://github.com/sct/overseerr/issues/251)
|
||||
* **frontend:** encode special characters in search input to prevent crashing router ([15013d6](https://github.com/sct/overseerr/commit/15013d6c5dbff15704c7c30d261d68a265e7f2d7)), closes [#252](https://github.com/sct/overseerr/issues/252)
|
||||
* **plex sync:** catch errors that occur during processMovie ([edbbccf](https://github.com/sct/overseerr/commit/edbbccf3ae623430294f1a5c3fd2728dbd42e555)), closes [#244](https://github.com/sct/overseerr/issues/244) [#246](https://github.com/sct/overseerr/issues/246) [#250](https://github.com/sct/overseerr/issues/250)
|
||||
* **services:** improve logging for adding movies to Radarr ([6c1ee83](https://github.com/sct/overseerr/commit/6c1ee830a183f89bb1fe96a181a7d61684e23b22))
|
||||
* **services:** radarr/sonarr will use the correct default server ([0658b79](https://github.com/sct/overseerr/commit/0658b7943e1ab25816db9da34d4c9ea808d9203d))
|
||||
|
||||
## [1.3.1](https://github.com/sct/overseerr/compare/v1.3.0...v1.3.1) (2020-12-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** also convert activeProfileId to a number for radarr/sonarr submissions ([7bf924f](https://github.com/sct/overseerr/commit/7bf924f7e94a0e0834f41b4ec067ed277c652766))
|
||||
* **frontend:** also convert ports to numbers when saving radarr/sonarr servers ([c53dc3b](https://github.com/sct/overseerr/commit/c53dc3b15da522c6e6ab76bbc9d15008a8a9fb9d))
|
||||
* **frontend:** new radarr/sonarr ports will be converted to a number before posting ([92c9001](https://github.com/sct/overseerr/commit/92c9001c9d1f2cbd272a5897ea1157d2cadbce2d))
|
||||
|
||||
# [1.3.0](https://github.com/sct/overseerr/compare/v1.2.0...v1.3.0) (2020-12-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** correctly generate clientId on first startup ([5f09e83](https://github.com/sct/overseerr/commit/5f09e83ed870336638d3e9d94fcf55ead928e737))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** add full cast page for movies and series ([051f1b3](https://github.com/sct/overseerr/commit/051f1b3e899bf749e632743e5c8d45a02b621998))
|
||||
* **lang:** translated using Weblate (Dutch) ([1ab3a4b](https://github.com/sct/overseerr/commit/1ab3a4b80a081d7e4a201f1290cd270ed5b38ac7))
|
||||
* **lang:** translated using Weblate (English) ([0949c9b](https://github.com/sct/overseerr/commit/0949c9b334b3a4b6c342517a157a9e2b7596f2f0))
|
||||
* **lang:** translated using Weblate (French) ([f943701](https://github.com/sct/overseerr/commit/f943701e13c7f0de5a711302597858cc898b16e2))
|
||||
* **lang:** translated using Weblate (French) ([30d04ce](https://github.com/sct/overseerr/commit/30d04ce35adc21070cce37ab10384154afda191b))
|
||||
* **lang:** translated using Weblate (German) ([7bf9add](https://github.com/sct/overseerr/commit/7bf9addd13a707aac23b64ef3f1733e491d40a4e))
|
||||
* **lang:** translated using Weblate (German) ([b6e60a4](https://github.com/sct/overseerr/commit/b6e60a412b30907aea751a4cf1ce0cc8230f9814))
|
||||
* **lang:** translated using Weblate (Japanese) ([08e968f](https://github.com/sct/overseerr/commit/08e968fd0097ec7b2a65de064ed5b07e7c49ef39))
|
||||
* **lang:** translated using Weblate (Norwegian Bokmål) ([83efb0e](https://github.com/sct/overseerr/commit/83efb0e3d4d96b6a2d2ebdd85d36c9d78c1717b2))
|
||||
* **lang:** translated using Weblate (Russian) ([0d8e0d0](https://github.com/sct/overseerr/commit/0d8e0d0352f72fdb65ee8f054371eae08c39fe33))
|
||||
|
||||
# [1.2.0](https://github.com/sct/overseerr/compare/v1.1.0...v1.2.0) (2020-12-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** person cards now show correctly in ListView's ([ccb9855](https://github.com/sct/overseerr/commit/ccb98553f104c1aebd33796b7090cc9bbe964bd7))
|
||||
* **frontend:** properly remove site overlay when closing modals ([3fa7ff9](https://github.com/sct/overseerr/commit/3fa7ff9858d14d132151f3329164d55d74638f53))
|
||||
* **frontend:** switch to using Transition component for modals ([b16fbaf](https://github.com/sct/overseerr/commit/b16fbafa1f3d5e105c0a4ba6f1d66aa064019636)), closes [#220](https://github.com/sct/overseerr/issues/220)
|
||||
* fix missing personid in Discover ([d8060af](https://github.com/sct/overseerr/commit/d8060afe02574337f51b88cab0a0f824976ac721))
|
||||
* missing personId in ListView component ([6502feb](https://github.com/sct/overseerr/commit/6502feb1a5be3c6daab33230814fe74632c87f7e))
|
||||
* **frontend:** update overflow issues with seasons + email ([#217](https://github.com/sct/overseerr/issues/217)) ([2d0afb2](https://github.com/sct/overseerr/commit/2d0afb29d37798a626e3f182571ccce43d80063c)), closes [#216](https://github.com/sct/overseerr/issues/216)
|
||||
* **lang:** fix missing i18n string for agent enabled in email notification page ([42788ad](https://github.com/sct/overseerr/commit/42788adb75f7d23e68327688b1c542dd047e9609))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **lang:** update language files ([8cd067b](https://github.com/sct/overseerr/commit/8cd067b6e9df1a3c8f4056789436a31177703986))
|
||||
* person details page ([d6eb3ae](https://github.com/sct/overseerr/commit/d6eb3ae64ef46bd62145010d3029e272676487c3))
|
||||
* **lang:** add nb-NO and de language support to app ([d38b28d](https://github.com/sct/overseerr/commit/d38b28d2061b38366989ff412957a5dee5766c6f))
|
||||
* **lang:** add support for dutch language ([df94db0](https://github.com/sct/overseerr/commit/df94db050bf68a925118e0ce865d27178b702f9e))
|
||||
* **lang:** add support for russian languge ([8d8e750](https://github.com/sct/overseerr/commit/8d8e7509826514eebc859374d2e1ab212cc442d1))
|
||||
* **lang:** added translation using Weblate (Russian) ([887f5dd](https://github.com/sct/overseerr/commit/887f5dd487b61676029652d99cbc5b40213aa22e))
|
||||
* **lang:** translated using Weblate (French) ([30a8934](https://github.com/sct/overseerr/commit/30a8934626fa2d47e95b5925d7e4227a0d0aa728))
|
||||
* **lang:** translated using Weblate (German) ([44dbb74](https://github.com/sct/overseerr/commit/44dbb745b6216ce19fab4740520785c6414cf367))
|
||||
* **lang:** translated using Weblate (Japanese) ([a494507](https://github.com/sct/overseerr/commit/a494507dfeafb0cfd2bd66fb01138522e0e80737))
|
||||
* **lang:** translated using Weblate (Russian) ([86cadb8](https://github.com/sct/overseerr/commit/86cadb8283fcab8745b4c09f8429fd9e46708813))
|
||||
* **lang:** translations update from Weblate ([#201](https://github.com/sct/overseerr/issues/201)) ([b0c663b](https://github.com/sct/overseerr/commit/b0c663baccd994e234b4d41d86486c3af4906344))
|
||||
|
||||
# [1.1.0](https://github.com/sct/overseerr/compare/v1.0.0...v1.1.0) (2020-12-08)
|
||||
|
||||
|
||||
|
||||
132
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
[ryan@sct.dev](mailto:ryan@sct.dev).
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][mozilla coc].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][faq]. Translations are available
|
||||
at [https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
|
||||
[mozilla coc]: https://github.com/mozilla/diversity
|
||||
[faq]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
79
CONTRIBUTING.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Contributing to Overseerr
|
||||
|
||||
All help is welcome and greatly appreciated. If you would like to contribute to the project the steps below can get you started:
|
||||
|
||||
## Development
|
||||
|
||||
### Tools Required
|
||||
|
||||
- HTML/Typescript/Javascript editor of choice. ([VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install.)
|
||||
- [NodeJS](https://nodejs.org/en/download/) (Node 12.x.x or higher)
|
||||
- [Yarn](https://yarnpkg.com/)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
### 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.
|
||||
```
|
||||
git clone https://github.com/YOUR_USERNAME/overseerr.git
|
||||
cd overseerr/
|
||||
```
|
||||
2. Add the remote upstream.
|
||||
|
||||
```
|
||||
git remote add upstream https://github.com/sct/overseerr.git
|
||||
```
|
||||
|
||||
3. Create a new branch
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
- If you are taking on an existing bug or feature ticket, please comment on the [GitHub Issue](https://github.com/sct/overseerr/issues) to avoid multiple people working on the same thing.
|
||||
- All commits **must** follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
- It is okay if you squash your PR down to be a single commit that fits this standard.
|
||||
- PRs with commits not following this standard will not be merged.
|
||||
- Please make meaningful commits, or squash them
|
||||
- Always rebase your commit to the latest `develop` branch. Do not merge develop into your branch.
|
||||
- It is your responsibility to keep your branch up to date. It will not be merged unless its rebased off the latest develop branch.
|
||||
- You can create a Draft pull request early to get feedback on your work.
|
||||
- Your code must be formatted correctly or the tests will fail.
|
||||
- We use Prettier to format our codebase. It should auto run with a git hook, but its recommended to have a Prettier extension installed in your editor and have it format on save.
|
||||
- If you have questions or need help, you can reach out in [GitHub Discussions](https://github.com/sct/overseerr/discussions) or in our [Discord](https://discord.gg/PkCWJSeCk7).
|
||||
- Only open pull requests to `develop`. Never `master`. Any PR's opened to master will be closed.
|
||||
|
||||
## Translation
|
||||
|
||||
We use [Weblate](https://hosted.weblate.org/engage/overseerr/) for our translations so please feel free to contribute to localizing Overseerr!
|
||||
|
||||
## Attribution
|
||||
|
||||
This contribution guide was inspired by the [Next.js](https://github.com/vercel/next.js) and [Radarr](https://github.com/Radarr/Radarr) contribution guides.
|
||||
12
Dockerfile
@@ -1,5 +1,8 @@
|
||||
FROM node:12.18-alpine AS BUILD_IMAGE
|
||||
|
||||
ARG COMMIT_TAG
|
||||
ENV COMMIT_TAG=${COMMIT_TAG}
|
||||
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
@@ -12,6 +15,11 @@ RUN yarn cache clean
|
||||
|
||||
FROM node:12.18-alpine
|
||||
|
||||
ARG COMMIT_TAG
|
||||
ENV COMMIT_TAG=${COMMIT_TAG}
|
||||
|
||||
RUN apk add tzdata
|
||||
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
@@ -20,4 +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
|
||||
|
||||
115
README.md
@@ -6,7 +6,7 @@
|
||||
<img src="https://github.com/sct/overseerr/workflows/Overseerr%20CI/badge.svg" alt="Overseerr CI">
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/ySfaEUcQ">
|
||||
<a href="https://discord.gg/PkCWJSeCk7">
|
||||
<img src="https://img.shields.io/discord/783137440809746482" alt="Discord">
|
||||
</a>
|
||||
<img src="https://img.shields.io/docker/pulls/sctx/overseerr" alt="Docker pulls">
|
||||
@@ -14,48 +14,58 @@
|
||||
<img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/svg-badge.svg" alt="Translation status" />
|
||||
</a>
|
||||
<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-26-orange.svg"/></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
</p>
|
||||
|
||||
**Overseerr** is a tool for managing requests for your media library. It integrates with existing services such as **Sonarr** and **Radarr**!
|
||||
**Overseerr** is a free and open source software application for managing requests for your media library. It integrates with your existing services such as **Sonarr**, **Radarr** and **Plex**!
|
||||
|
||||
## Current Features
|
||||
|
||||
- Full Plex integration. Login and manage user access with Plex!
|
||||
- Integrates easily with your existing services. Currently Overseerr supports Sonarr and Radarr. More in the future!
|
||||
- Integrates easily with your existing services. Currently Overseerr supports Sonarr and Radarr. More to come!
|
||||
- Syncs to your Plex library to know what titles you already have.
|
||||
- Complex request system that allows users to request individual seasons or movies in a friendly, easy to use UI.
|
||||
- Complex request system allowing users to request individual seasons or movies in a friendly, easy to use UI.
|
||||
- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests.
|
||||
- Granular permissions system
|
||||
- Granular permission system
|
||||
- Mobile friendly design, for when you need to approve requests on the go!
|
||||
|
||||
## In Development
|
||||
|
||||
- User profiles
|
||||
- User settings page to give users the ability to modify their Overseerr experience to their liking
|
||||
- Version update notifications in-app
|
||||
- User profiles.
|
||||
- User settings page (to give users the ability to modify their Overseerr experience to their liking).
|
||||
- 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 do not use Plex)
|
||||
- Compatiblity APIs to work with existing tools in your system
|
||||
- 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, the only distribution of Overseerr is through Docker images. If you have Docker, you can run Overseerr with the following command:
|
||||
Currently, Overseerr is only distributed through Docker images. If you have Docker, you can run Overseerr as per:
|
||||
|
||||
```
|
||||
docker run -d \
|
||||
-e LOG_LEVEL=info \
|
||||
-e TZ=Asia/Tokyo \
|
||||
-p 5055:3000 \
|
||||
-v /path/to/appdata/config:/config \
|
||||
-p 5055:5055 \
|
||||
-v /path/to/appdata/config:/app/config \
|
||||
--restart unless-stopped \
|
||||
sctx/overseer
|
||||
sctx/overseerr
|
||||
```
|
||||
|
||||
After running Overseerr for the first time, visit the web UI at http://[address]:5055 and complete the setup steps to configure Overseerr.
|
||||
After running Overseerr for the first time, configure it by visiting the web UI at http://[address]:5055 and completing the setup steps.
|
||||
|
||||
⚠️ 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! ⚠️
|
||||
|
||||
@@ -65,34 +75,71 @@ After running Overseerr for the first time, visit the web UI at http://[address]
|
||||
|
||||
## Support
|
||||
|
||||
- You can reach us for support on [Discord](https://discord.gg/ySfaEUcQ).
|
||||
- Bugs can be opened with an issue on [Github](https://github.com/sct/overseerr/issues).
|
||||
- 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).
|
||||
|
||||
## API Documentation
|
||||
|
||||
Full API documentation will soon be published automatically and available outside of running the app. But currently, you can access the api docs by running Overseerr locally and visiting http://localhost:3000/api-docs
|
||||
Full API documentation will soon be published automatically and available outside of running the app. Currently, you can access the API docs by running Overseerr locally and visiting http://localhost:5055/api-docs
|
||||
|
||||
## Contribution
|
||||
## Community
|
||||
|
||||
Anyone is welcome to contribute to Docker and pull requests are greatly appreciated! Contributors will be recognized in the future on this very README.
|
||||
You can ask questions, share ideas, and more in [GitHub Discussions](https://github.com/sct/overseerr/discussions).
|
||||
|
||||
### Developing Overseerr
|
||||
If you would like to chat with community members you can join the [Overseerr Discord](https://discord.gg/PkCWJSeCk7).
|
||||
|
||||
You can develop Overseer entirely in docker. Make sure you have [Docker Desktop](https://www.docker.com/products/docker-desktop) installed before continuing.
|
||||
Our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md) applies to all Overseerr community channels.
|
||||
|
||||
1. Make sure you have [Docker Desktop](https://www.docker.com/products/docker-desktop) installed.
|
||||
2. Run `docker-compose up -d` to start the server.
|
||||
3. Access the container at http://localhost:3000
|
||||
## Contributing
|
||||
|
||||
If Docker isn't your jam, you can always run Overseer with the following yarn commands:
|
||||
You can help build Overseerr too! Check out our [Contribution Guide](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md) to get started.
|
||||
|
||||
```
|
||||
yarn
|
||||
yarn dev
|
||||
```
|
||||
## Contributors ✨
|
||||
|
||||
You will need NodeJS installed. Once it's built and running, access it locally at http://localhost:3000 just like Docker.
|
||||
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
|
||||
|
||||
### Translation
|
||||
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||
<!-- prettier-ignore-start -->
|
||||
<!-- markdownlint-disable -->
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><a href="https://sct.dev"><img src="https://avatars1.githubusercontent.com/u/234213?v=4" width="100px;" alt=""/><br /><sub><b>sct</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=sct" title="Code">💻</a> <a href="#design-sct" title="Design">🎨</a> <a href="#ideas-sct" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center"><a href="https://github.com/azoitos"><img src="https://avatars2.githubusercontent.com/u/26529049?v=4" width="100px;" alt=""/><br /><sub><b>Alex Zoitos</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=azoitos" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/OwsleyJr"><img src="https://avatars3.githubusercontent.com/u/8635678?v=4" width="100px;" alt=""/><br /><sub><b>Brandon Cohen</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=OwsleyJr" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=OwsleyJr" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/Ahreluth"><img src="https://avatars2.githubusercontent.com/u/75682440?v=4" width="100px;" alt=""/><br /><sub><b>Ahreluth</b></sub></a><br /><a href="#translation-Ahreluth" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/KovalevArtem"><img src="https://avatars0.githubusercontent.com/u/36500228?v=4" width="100px;" alt=""/><br /><sub><b>KovalevArtem</b></sub></a><br /><a href="#translation-KovalevArtem" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/GiyomuWeb"><img src="https://avatars0.githubusercontent.com/u/62489209?v=4" width="100px;" alt=""/><br /><sub><b>GiyomuWeb</b></sub></a><br /><a href="#translation-GiyomuWeb" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/angrycuban13"><img src="https://avatars3.githubusercontent.com/u/39564898?v=4" width="100px;" alt=""/><br /><sub><b>Angry Cuban</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=angrycuban13" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/jvennik"><img src="https://avatars3.githubusercontent.com/u/6672637?v=4" width="100px;" alt=""/><br /><sub><b>jvennik</b></sub></a><br /><a href="#translation-jvennik" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/darknessgp"><img src="https://avatars0.githubusercontent.com/u/1521243?v=4" width="100px;" alt=""/><br /><sub><b>darknessgp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=darknessgp" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/saltydk"><img src="https://avatars1.githubusercontent.com/u/6587950?v=4" width="100px;" alt=""/><br /><sub><b>salty</b></sub></a><br /><a href="#infra-saltydk" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center"><a href="https://github.com/Shutruk"><img src="https://avatars2.githubusercontent.com/u/9198633?v=4" width="100px;" alt=""/><br /><sub><b>Shutruk</b></sub></a><br /><a href="#translation-Shutruk" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/krystiancharubin"><img src="https://avatars2.githubusercontent.com/u/17775600?v=4" width="100px;" alt=""/><br /><sub><b>Krystian Charubin</b></sub></a><br /><a href="#design-krystiancharubin" title="Design">🎨</a></td>
|
||||
<td align="center"><a href="https://github.com/kieron"><img src="https://avatars2.githubusercontent.com/u/8655212?v=4" width="100px;" alt=""/><br /><sub><b>Kieron Boswell</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=kieron" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/samwiseg0"><img src="https://avatars1.githubusercontent.com/u/2241731?v=4" width="100px;" alt=""/><br /><sub><b>samwiseg0</b></sub></a><br /><a href="#question-samwiseg0" title="Answering Questions">💬</a> <a href="#infra-samwiseg0" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/ecelebi29"><img src="https://avatars2.githubusercontent.com/u/8337120?v=4" width="100px;" alt=""/><br /><sub><b>ecelebi29</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/mmozeiko"><img src="https://avatars3.githubusercontent.com/u/1665010?v=4" width="100px;" alt=""/><br /><sub><b>Mārtiņš Možeiko</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=mmozeiko" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/mazzetta86"><img src="https://avatars2.githubusercontent.com/u/45591560?v=4" width="100px;" alt=""/><br /><sub><b>mazzetta86</b></sub></a><br /><a href="#translation-mazzetta86" title="Translation">🌍</a></td>
|
||||
<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>
|
||||
|
||||
We use [Weblate](https://hosted.weblate.org/engage/overseerr/) for our translations so please feel free to contribute to localizing Overseerr!
|
||||
<!-- markdownlint-enable -->
|
||||
<!-- prettier-ignore-end -->
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
@@ -5,7 +5,7 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile.local
|
||||
ports:
|
||||
- 3000:3000
|
||||
- 5055:5055
|
||||
volumes:
|
||||
- .:/app:rw,cached
|
||||
- /app/node_modules
|
||||
|
||||
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,9 +2,11 @@ const devConfig = {
|
||||
type: 'sqlite',
|
||||
database: 'config/db/db.sqlite3',
|
||||
synchronize: true,
|
||||
migrationsRun: false,
|
||||
logging: false,
|
||||
entities: ['server/entity/**/*.ts'],
|
||||
migrations: ['server/migration/**/*.ts'],
|
||||
subscribers: ['server/subscriber/**/*.ts'],
|
||||
cli: {
|
||||
entitiesDir: 'server/entity',
|
||||
migrationsDir: 'server/migration',
|
||||
@@ -18,7 +20,8 @@ const prodConfig = {
|
||||
logging: false,
|
||||
entities: ['dist/entity/**/*.js'],
|
||||
migrations: ['dist/migration/**/*.js'],
|
||||
migrationsRun: true,
|
||||
migrationsRun: false,
|
||||
subscribers: ['dist/subscriber/**/*.js'],
|
||||
cli: {
|
||||
entitiesDir: 'dist/entity',
|
||||
migrationsDir: 'dist/migration',
|
||||
|
||||
@@ -58,6 +58,9 @@ components:
|
||||
applicationUrl:
|
||||
type: string
|
||||
example: https://os.example.com
|
||||
defaultPermissions:
|
||||
type: number
|
||||
example: 32
|
||||
PlexLibrary:
|
||||
type: object
|
||||
properties:
|
||||
@@ -375,10 +378,41 @@ components:
|
||||
example: 1
|
||||
logoPath:
|
||||
type: string
|
||||
nullable: true
|
||||
originCountry:
|
||||
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:
|
||||
@@ -404,6 +438,10 @@ components:
|
||||
$ref: '#/components/schemas/Genre'
|
||||
homepage:
|
||||
type: string
|
||||
relatedVideos:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RelatedVideo'
|
||||
originalLanguage:
|
||||
type: string
|
||||
originalTitle:
|
||||
@@ -428,18 +466,14 @@ components:
|
||||
releaseDate:
|
||||
type: string
|
||||
revenue:
|
||||
type: string
|
||||
type: number
|
||||
nullable: true
|
||||
runtime:
|
||||
type: number
|
||||
spokenLanguages:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
iso_639_1:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
$ref: '#/components/schemas/SpokenLanguage'
|
||||
status:
|
||||
type: string
|
||||
tagline:
|
||||
@@ -463,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:
|
||||
@@ -476,6 +523,7 @@ components:
|
||||
type: string
|
||||
airDate:
|
||||
type: string
|
||||
nullable: true
|
||||
episodeNumber:
|
||||
type: number
|
||||
overview:
|
||||
@@ -483,11 +531,12 @@ components:
|
||||
productionCode:
|
||||
type: string
|
||||
seasonNumber:
|
||||
type: string
|
||||
type: number
|
||||
showId:
|
||||
type: number
|
||||
stillPath:
|
||||
type: string
|
||||
nullable: true
|
||||
voteAverage:
|
||||
type: number
|
||||
voteCount:
|
||||
@@ -499,6 +548,7 @@ components:
|
||||
type: number
|
||||
airDate:
|
||||
type: string
|
||||
nullable: true
|
||||
episodeCount:
|
||||
type: number
|
||||
name:
|
||||
@@ -536,6 +586,7 @@ components:
|
||||
type: number
|
||||
profilePath:
|
||||
type: string
|
||||
nullable: true
|
||||
episodeRunTime:
|
||||
type: array
|
||||
items:
|
||||
@@ -586,6 +637,10 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ProductionCompany'
|
||||
spokenLanguages:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SpokenLanguage'
|
||||
seasons:
|
||||
type: array
|
||||
items:
|
||||
@@ -611,6 +666,10 @@ components:
|
||||
$ref: '#/components/schemas/Crew'
|
||||
externalIds:
|
||||
$ref: '#/components/schemas/ExternalIds'
|
||||
keywords:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Keyword'
|
||||
mediaInfo:
|
||||
$ref: '#/components/schemas/MediaInfo'
|
||||
MediaRequest:
|
||||
@@ -637,7 +696,6 @@ components:
|
||||
readOnly: true
|
||||
requestedBy:
|
||||
$ref: '#/components/schemas/User'
|
||||
readOnly: true
|
||||
modifiedBy:
|
||||
anyOf:
|
||||
- $ref: '#/components/schemas/User'
|
||||
@@ -658,6 +716,7 @@ components:
|
||||
tvdbId:
|
||||
type: number
|
||||
readOnly: true
|
||||
nullable: true
|
||||
status:
|
||||
type: number
|
||||
requests:
|
||||
@@ -696,6 +755,7 @@ components:
|
||||
type: number
|
||||
profilePath:
|
||||
type: string
|
||||
nullable: true
|
||||
Crew:
|
||||
type: object
|
||||
properties:
|
||||
@@ -715,25 +775,34 @@ components:
|
||||
type: string
|
||||
profilePath:
|
||||
type: string
|
||||
nullable: true
|
||||
ExternalIds:
|
||||
type: object
|
||||
properties:
|
||||
facebookId:
|
||||
type: string
|
||||
nullable: true
|
||||
freebaseId:
|
||||
type: string
|
||||
nullable: true
|
||||
freebaseMid:
|
||||
type: string
|
||||
nullable: true
|
||||
imdbId:
|
||||
type: string
|
||||
nullable: true
|
||||
instagramId:
|
||||
type: string
|
||||
nullable: true
|
||||
tvdbId:
|
||||
type: string
|
||||
type: number
|
||||
nullable: true
|
||||
tvrageId:
|
||||
type: string
|
||||
type: number
|
||||
nullable: true
|
||||
twitterId:
|
||||
type: string
|
||||
nullable: true
|
||||
ServiceProfile:
|
||||
type: object
|
||||
properties:
|
||||
@@ -772,6 +841,56 @@ 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
|
||||
PushoverSettings:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
example: false
|
||||
types:
|
||||
type: number
|
||||
example: 2
|
||||
options:
|
||||
type: object
|
||||
properties:
|
||||
accessToken:
|
||||
type: string
|
||||
userToken:
|
||||
type: string
|
||||
priority:
|
||||
type: number
|
||||
sound:
|
||||
type: string
|
||||
NotificationEmailSettings:
|
||||
type: object
|
||||
properties:
|
||||
@@ -787,6 +906,9 @@ components:
|
||||
emailFrom:
|
||||
type: string
|
||||
example: no-reply@example.com
|
||||
senderName:
|
||||
type: string
|
||||
example: Overseerr
|
||||
smtpHost:
|
||||
type: string
|
||||
example: 127.0.0.1
|
||||
@@ -802,7 +924,9 @@ components:
|
||||
authPass:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
allowSelfSigned:
|
||||
type: boolean
|
||||
example: false
|
||||
PersonDetail:
|
||||
type: object
|
||||
properties:
|
||||
@@ -887,6 +1011,8 @@ components:
|
||||
type: string
|
||||
character:
|
||||
type: string
|
||||
mediaInfo:
|
||||
$ref: '#/components/schemas/MediaInfo'
|
||||
CreditCrew:
|
||||
type: object
|
||||
properties:
|
||||
@@ -941,17 +1067,85 @@ components:
|
||||
type: string
|
||||
job:
|
||||
type: string
|
||||
mediaInfo:
|
||||
$ref: '#/components/schemas/MediaInfo'
|
||||
Keyword:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
example: 1
|
||||
name:
|
||||
type: string
|
||||
example: 'anime'
|
||||
SpokenLanguage:
|
||||
type: object
|
||||
properties:
|
||||
englishName:
|
||||
type: string
|
||||
example: 'English'
|
||||
nullable: true
|
||||
iso_639_1:
|
||||
type: string
|
||||
example: 'en'
|
||||
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
|
||||
name: connect.sid
|
||||
in: cookie
|
||||
apiKey:
|
||||
type: apiKey
|
||||
in: header
|
||||
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
|
||||
description: Retreives all main settings in JSON format
|
||||
description: Retrieves all main settings in JSON format
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
@@ -979,6 +1173,19 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MainSettings'
|
||||
/settings/main/regenerate:
|
||||
get:
|
||||
summary: Returns main settings with newly generated API Key
|
||||
description: Retreives all main settings in JSON format with new API Key
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MainSettings'
|
||||
/settings/plex:
|
||||
get:
|
||||
summary: Returns plex settings
|
||||
@@ -1116,7 +1323,7 @@ paths:
|
||||
/settings/radarr/test:
|
||||
post:
|
||||
summary: Test radarr configuration
|
||||
description: Test if the provided Radarr congifuration values are valid. Returns profiles and root folders on success
|
||||
description: Test if the provided Radarr configuration values are valid. Returns profiles and root folders on success
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
@@ -1260,7 +1467,7 @@ paths:
|
||||
/settings/sonarr/test:
|
||||
post:
|
||||
summary: Test Sonarr configuration
|
||||
description: Test if the provided Sonarr congifuration values are valid. Returns profiles and root folders on success
|
||||
description: Test if the provided Sonarr configuration values are valid. Returns profiles and root folders on success
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
@@ -1363,7 +1570,7 @@ paths:
|
||||
/settings/initialize:
|
||||
get:
|
||||
summary: Set the application as initialized
|
||||
description: Sets the app as initalized and allows the user to navigate to pages other than the setup page
|
||||
description: Sets the app as initialized and allows the user to navigate to pages other than the setup page
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
@@ -1426,6 +1633,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
|
||||
@@ -1457,6 +1679,186 @@ 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/pushover:
|
||||
get:
|
||||
summary: Return current pushover notification settings
|
||||
description: Returns current pushover notification settings in JSON format
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
'200':
|
||||
description: Returned pushover settings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PushoverSettings'
|
||||
post:
|
||||
summary: Update pushover notification settings
|
||||
description: Update current pushover notification settings with provided values
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PushoverSettings'
|
||||
responses:
|
||||
'200':
|
||||
description: 'Values were sucessfully updated'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PushoverSettings'
|
||||
/settings/notifications/pushover/test:
|
||||
post:
|
||||
summary: Test the provided pushover settings
|
||||
description: Sends a test notification to the pushover agent
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PushoverSettings'
|
||||
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
|
||||
description: Returns current server stats in JSON format
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
'200':
|
||||
description: Returned about settings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
example: '1.0.0'
|
||||
totalRequests:
|
||||
type: number
|
||||
example: 100
|
||||
totalMediaItems:
|
||||
type: number
|
||||
example: 100
|
||||
tz:
|
||||
type: string
|
||||
nullable: true
|
||||
example: Asia/Tokyo
|
||||
/auth/me:
|
||||
get:
|
||||
summary: Returns the currently logged in user
|
||||
@@ -1551,6 +1953,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
|
||||
@@ -1823,6 +2244,51 @@ paths:
|
||||
- $ref: '#/components/schemas/MovieResult'
|
||||
- $ref: '#/components/schemas/TvResult'
|
||||
- $ref: '#/components/schemas/PersonResult'
|
||||
/discover/keyword/{keywordId}/movies:
|
||||
get:
|
||||
summary: Request list of movies from keyword
|
||||
description: Returns list of movies based on provided keyword ID in JSON format
|
||||
tags:
|
||||
- search
|
||||
parameters:
|
||||
- in: path
|
||||
name: keywordId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
example: 207317
|
||||
- in: query
|
||||
name: page
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
default: 1
|
||||
- in: query
|
||||
name: language
|
||||
schema:
|
||||
type: string
|
||||
example: en
|
||||
responses:
|
||||
'200':
|
||||
description: List of movies
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
page:
|
||||
type: number
|
||||
example: 1
|
||||
totalPages:
|
||||
type: number
|
||||
example: 20
|
||||
totalResults:
|
||||
type: number
|
||||
example: 200
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/MovieResult'
|
||||
/request:
|
||||
get:
|
||||
summary: Get all requests
|
||||
@@ -1945,6 +2411,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
|
||||
@@ -2374,7 +2864,7 @@ paths:
|
||||
crew:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/CreditCrew"
|
||||
$ref: '#/components/schemas/CreditCrew'
|
||||
id:
|
||||
type: number
|
||||
/media:
|
||||
@@ -2439,6 +2929,32 @@ 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: []
|
||||
- apiKey: []
|
||||
|
||||
97
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "overseerr",
|
||||
"version": "1.1.0",
|
||||
"version": "1.16.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",
|
||||
@@ -9,53 +9,57 @@
|
||||
"build": "yarn build:next && yarn build:server",
|
||||
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\"",
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
"i18n:extract": "extract-messages -l=en,ja,fr -o src/i18n/locale -d en --flat true './src/**/!(*.test).{ts,tsx}'",
|
||||
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault false './src/**/!(*.test).{ts,tsx}'",
|
||||
"migration:generate": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:generate",
|
||||
"migration:create": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:create",
|
||||
"migration:run": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:run",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svgr/webpack": "^5.4.0",
|
||||
"axios": "^0.20.0",
|
||||
"@svgr/webpack": "^5.5.0",
|
||||
"axios": "^0.21.1",
|
||||
"body-parser": "^1.19.0",
|
||||
"bowser": "^2.11.0",
|
||||
"connect-typeorm": "^1.1.4",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"email-templates": "^8.0.0",
|
||||
"email-templates": "^8.0.2",
|
||||
"express": "^4.17.1",
|
||||
"express-openapi-validator": "^3.16.15",
|
||||
"express-openapi-validator": "^4.10.2",
|
||||
"express-session": "^1.17.1",
|
||||
"formik": "^2.2.1",
|
||||
"formik": "^2.2.6",
|
||||
"intl": "^1.2.5",
|
||||
"lodash": "^4.17.20",
|
||||
"next": "^10.0.3",
|
||||
"next": "^10.0.4",
|
||||
"node-schedule": "^1.3.2",
|
||||
"nodemailer": "^6.4.16",
|
||||
"nookies": "^2.4.0",
|
||||
"nodemailer": "^6.4.17",
|
||||
"nookies": "^2.5.0",
|
||||
"plex-api": "^5.3.1",
|
||||
"pug": "^3.0.0",
|
||||
"react": "16.13.1",
|
||||
"react-dom": "16.13.1",
|
||||
"react": "17.0.1",
|
||||
"react-dom": "17.0.1",
|
||||
"react-intersection-observer": "^8.31.0",
|
||||
"react-intl": "^5.8.5",
|
||||
"react-intl": "^5.10.11",
|
||||
"react-markdown": "^5.0.3",
|
||||
"react-spring": "^8.0.27",
|
||||
"react-toast-notifications": "^2.4.0",
|
||||
"react-transition-group": "^4.4.1",
|
||||
"react-use-clipboard": "1.0.1",
|
||||
"react-truncate-markup": "^5.0.1",
|
||||
"react-use-clipboard": "1.0.7",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"sqlite3": "^5.0.0",
|
||||
"swagger-ui-express": "^4.1.4",
|
||||
"swr": "^0.3.5",
|
||||
"typeorm": "^0.2.26",
|
||||
"uuid": "^8.3.1",
|
||||
"swagger-ui-express": "^4.1.6",
|
||||
"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.29.3"
|
||||
"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",
|
||||
@@ -67,11 +71,11 @@
|
||||
"@tailwindcss/typography": "^0.3.1",
|
||||
"@types/body-parser": "^1.19.0",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
"@types/email-templates": "^7.1.0",
|
||||
"@types/express": "^4.17.8",
|
||||
"@types/email-templates": "^8.0.0",
|
||||
"@types/express": "^4.17.9",
|
||||
"@types/express-session": "^1.17.0",
|
||||
"@types/lodash": "^4.14.165",
|
||||
"@types/node": "^14.14.10",
|
||||
"@types/lodash": "^4.14.167",
|
||||
"@types/node": "^14.14.20",
|
||||
"@types/node-schedule": "^1.3.1",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"@types/react": "^17.0.0",
|
||||
@@ -82,34 +86,34 @@
|
||||
"@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.12.0",
|
||||
"@typescript-eslint/parser": "^4.12.0",
|
||||
"autoprefixer": "^9",
|
||||
"babel-plugin-react-intl": "^8.2.2",
|
||||
"babel-plugin-react-intl": "^8.2.25",
|
||||
"babel-plugin-react-intl-auto": "^3.3.0",
|
||||
"commitizen": "^4.2.1",
|
||||
"copyfiles": "^2.4.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.17.0",
|
||||
"eslint-config-prettier": "^7.1.0",
|
||||
"eslint-plugin-formatjs": "^2.10.2",
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"eslint-plugin-prettier": "^3.2.0",
|
||||
"eslint-plugin-react": "^7.21.5",
|
||||
"eslint-plugin-prettier": "^3.3.1",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"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",
|
||||
"postcss-preset-env": "^6.7.0",
|
||||
"prettier": "^2.1.2",
|
||||
"semantic-release": "^17.3.0",
|
||||
"prettier": "^2.2.1",
|
||||
"semantic-release": "^17.3.1",
|
||||
"semantic-release-docker": "^2.2.0",
|
||||
"tailwindcss": "npm:@tailwindcss/postcss7-compat",
|
||||
"ts-node": "^9.0.0",
|
||||
"typescript": "^4.0.2"
|
||||
"ts-node": "^9.1.1",
|
||||
"typescript": "^4.1.3"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
@@ -167,10 +171,16 @@
|
||||
[
|
||||
"@semantic-release/exec",
|
||||
{
|
||||
"prepareCmd": "docker build -t sctx/overseerr ."
|
||||
"prepareCmd": "docker build --build-arg COMMIT_TAG=$GITHUB_SHA -t sctx/overseerr ."
|
||||
}
|
||||
],
|
||||
"semantic-release-docker"
|
||||
"semantic-release-docker",
|
||||
[
|
||||
"@semantic-release/github",
|
||||
{
|
||||
"addReleases": "bottom"
|
||||
}
|
||||
]
|
||||
],
|
||||
"branches": [
|
||||
"master"
|
||||
@@ -180,7 +190,8 @@
|
||||
{
|
||||
"path": "semantic-release-docker",
|
||||
"name": "sctx/overseerr"
|
||||
}
|
||||
},
|
||||
"@semantic-release/github"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
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":"#ffffff","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"
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ import { getSettings } from '../lib/settings';
|
||||
export interface PlexLibraryItem {
|
||||
ratingKey: string;
|
||||
parentRatingKey?: string;
|
||||
grandparentRatingKey?: string;
|
||||
title: string;
|
||||
guid: string;
|
||||
parentGuid?: string;
|
||||
type: 'movie' | 'show' | 'season';
|
||||
grandparentGuid?: string;
|
||||
type: 'movie' | 'show' | 'season' | 'episode';
|
||||
}
|
||||
|
||||
interface PlexLibraryResponse {
|
||||
@@ -20,6 +22,7 @@ export interface PlexLibrary {
|
||||
type: 'show' | 'movie';
|
||||
key: string;
|
||||
title: string;
|
||||
agent: string;
|
||||
}
|
||||
|
||||
interface PlexLibrariesResponse {
|
||||
@@ -61,7 +64,8 @@ class PlexAPI {
|
||||
|
||||
this.plexClient = new NodePlexAPI({
|
||||
hostname: settings.plex.ip,
|
||||
post: settings.plex.port,
|
||||
port: settings.plex.port,
|
||||
https: settings.plex.useSsl,
|
||||
token: plexToken,
|
||||
authenticator: {
|
||||
authenticate: (
|
||||
@@ -119,9 +123,9 @@ class PlexAPI {
|
||||
return response.MediaContainer.Metadata[0];
|
||||
}
|
||||
|
||||
public async getRecentlyAdded(): Promise<PlexLibraryItem[]> {
|
||||
public async getRecentlyAdded(id: string): Promise<PlexLibraryItem[]> {
|
||||
const response = await this.plexClient.query<PlexLibraryResponse>(
|
||||
'/library/recentlyAdded'
|
||||
`/library/sections/${id}/recentlyAdded`
|
||||
);
|
||||
|
||||
return response.MediaContainer.Metadata;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Axios, { AxiosInstance } from 'axios';
|
||||
import logger from '../logger';
|
||||
|
||||
interface RadarrMovieOptions {
|
||||
title: string;
|
||||
@@ -75,9 +76,7 @@ class RadarrAPI {
|
||||
}
|
||||
};
|
||||
|
||||
public addMovie = async (
|
||||
options: RadarrMovieOptions
|
||||
): Promise<RadarrMovie> => {
|
||||
public addMovie = async (options: RadarrMovieOptions): Promise<boolean> => {
|
||||
try {
|
||||
const response = await this.axios.post<RadarrMovie>(`/movie`, {
|
||||
title: options.title,
|
||||
@@ -94,9 +93,34 @@ class RadarrAPI {
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
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) {
|
||||
throw new Error(`[Radarr] Failed to add movie: ${e.message}`);
|
||||
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.',
|
||||
{
|
||||
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 RottenTomatoes {
|
||||
|
||||
public async getTVRatings(
|
||||
name: string,
|
||||
year: number
|
||||
year?: number
|
||||
): Promise<RTRating | null> {
|
||||
try {
|
||||
const response = await this.axios.get<RTMultiSearchResponse>(
|
||||
@@ -126,9 +126,13 @@ class RottenTomatoes {
|
||||
}
|
||||
);
|
||||
|
||||
const tvshow = response.data.tvSeries.find(
|
||||
(series) => series.startYear === year
|
||||
);
|
||||
let tvshow: RTTvSearchResult | undefined = response.data.tvSeries[0];
|
||||
|
||||
if (year) {
|
||||
tvshow = response.data.tvSeries.find(
|
||||
(series) => series.startYear === year
|
||||
);
|
||||
}
|
||||
|
||||
if (!tvshow) {
|
||||
return null;
|
||||
|
||||
@@ -6,7 +6,7 @@ interface SonarrSeason {
|
||||
monitored: boolean;
|
||||
}
|
||||
|
||||
interface SonarrSeries {
|
||||
export interface SonarrSeries {
|
||||
title: string;
|
||||
sortTitle: string;
|
||||
seasonCount: number;
|
||||
@@ -33,7 +33,7 @@ interface SonarrSeries {
|
||||
tvMazeId: number;
|
||||
firstAired: string;
|
||||
lastInfoSync?: string;
|
||||
seriesType: string;
|
||||
seriesType: 'standard' | 'daily' | 'anime';
|
||||
cleanTitle: string;
|
||||
imdbId: string;
|
||||
titleSlug: string;
|
||||
@@ -76,7 +76,9 @@ interface AddSeriesOptions {
|
||||
title: string;
|
||||
profileId: number;
|
||||
seasons: number[];
|
||||
seasonFolder: boolean;
|
||||
rootFolderPath: string;
|
||||
seriesType: SonarrSeries['seriesType'];
|
||||
monitored?: boolean;
|
||||
searchNow?: boolean;
|
||||
}
|
||||
@@ -114,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);
|
||||
|
||||
@@ -124,7 +126,7 @@ class SonarrAPI {
|
||||
|
||||
series.addOptions = {
|
||||
ignoreEpisodesWithFiles: true,
|
||||
searchForMissingEpisodes: true,
|
||||
searchForMissingEpisodes: options.searchNow,
|
||||
};
|
||||
|
||||
const newSeriesResponse = await this.axios.put<SonarrSeries>(
|
||||
@@ -132,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>(
|
||||
@@ -149,8 +167,10 @@ class SonarrAPI {
|
||||
monitored: false,
|
||||
}))
|
||||
),
|
||||
seasonFolder: options.seasonFolder,
|
||||
monitored: options.monitored,
|
||||
rootFolderPath: options.rootFolderPath,
|
||||
seriesType: options.seriesType,
|
||||
addOptions: {
|
||||
ignoreEpisodesWithFiles: true,
|
||||
searchForMissingEpisodes: options.searchNow,
|
||||
@@ -158,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',
|
||||
message: e.message,
|
||||
errorMessage: e.message,
|
||||
error: e,
|
||||
response: e?.response?.data,
|
||||
});
|
||||
throw new Error('Failed to add series');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
export const ANIME_KEYWORD_ID = 210024;
|
||||
|
||||
interface SearchOptions {
|
||||
query: string;
|
||||
page?: number;
|
||||
@@ -69,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 {
|
||||
@@ -188,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 {
|
||||
@@ -258,6 +283,11 @@ export interface TmdbTvDetails {
|
||||
name: string;
|
||||
origin_country: string;
|
||||
}[];
|
||||
spoken_languages: {
|
||||
english_name: string;
|
||||
iso_639_1: string;
|
||||
name: string;
|
||||
}[];
|
||||
seasons: TmdbTvSeasonResult[];
|
||||
status: string;
|
||||
type: string;
|
||||
@@ -268,6 +298,19 @@ export interface TmdbTvDetails {
|
||||
crew: TmdbCreditCrew[];
|
||||
};
|
||||
external_ids: TmdbExternalIds;
|
||||
keywords: {
|
||||
results: TmdbKeyword[];
|
||||
};
|
||||
videos: TmdbVideoResult;
|
||||
}
|
||||
|
||||
export interface TmdbVideoResult {
|
||||
results: TmdbVideo[];
|
||||
}
|
||||
|
||||
export interface TmdbKeyword {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TmdbPersonDetail {
|
||||
@@ -329,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;
|
||||
@@ -359,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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -418,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',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -437,7 +497,10 @@ class TheMovieDb {
|
||||
}): Promise<TmdbTvDetails> => {
|
||||
try {
|
||||
const response = await this.axios.get<TmdbTvDetails>(`/tv/${tvId}`, {
|
||||
params: { language, append_to_response: 'credits,external_ids' },
|
||||
params: {
|
||||
language,
|
||||
append_to_response: 'credits,external_ids,keywords,videos',
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
@@ -524,6 +587,32 @@ class TheMovieDb {
|
||||
}
|
||||
}
|
||||
|
||||
public async getMoviesByKeyword({
|
||||
keywordId,
|
||||
page = 1,
|
||||
language = 'en-US',
|
||||
}: {
|
||||
keywordId: number;
|
||||
page?: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbSearchMovieResponse> {
|
||||
try {
|
||||
const response = await this.axios.get<TmdbSearchMovieResponse>(
|
||||
`/keyword/${keywordId}/movies`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
language,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getTvRecommendations({
|
||||
tvId,
|
||||
page = 1,
|
||||
@@ -817,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;
|
||||
|
||||
@@ -8,14 +8,11 @@ import {
|
||||
UpdateDateColumn,
|
||||
getRepository,
|
||||
In,
|
||||
AfterUpdate,
|
||||
} from 'typeorm';
|
||||
import { MediaRequest } from './MediaRequest';
|
||||
import { MediaStatus, MediaType } from '../constants/media';
|
||||
import logger from '../logger';
|
||||
import Season from './Season';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
|
||||
@Entity()
|
||||
class Media {
|
||||
@@ -43,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'],
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ class Media {
|
||||
@Column({ type: 'varchar' })
|
||||
public mediaType: MediaType;
|
||||
|
||||
@Column({ unique: true })
|
||||
@Column()
|
||||
@Index()
|
||||
public tmdbId: number;
|
||||
|
||||
@@ -73,7 +73,7 @@ class Media {
|
||||
@Index()
|
||||
public tvdbId?: number;
|
||||
|
||||
@Column({ unique: true, nullable: true })
|
||||
@Column({ nullable: true })
|
||||
@Index()
|
||||
public imdbId?: string;
|
||||
|
||||
@@ -95,35 +95,12 @@ class Media {
|
||||
@UpdateDateColumn()
|
||||
public updatedAt: Date;
|
||||
|
||||
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||
public lastSeasonChange: Date;
|
||||
|
||||
constructor(init?: Partial<Media>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
@AfterUpdate()
|
||||
private async _notifyAvailable() {
|
||||
if (this.status === MediaStatus.AVAILABLE) {
|
||||
if (this.mediaType === MediaType.MOVIE) {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const relatedRequests = await requestRepository.find({
|
||||
where: { media: this },
|
||||
});
|
||||
|
||||
if (relatedRequests.length > 0) {
|
||||
const tmdb = new TheMovieDb();
|
||||
const movie = await tmdb.getMovie({ movieId: this.tmdbId });
|
||||
|
||||
relatedRequests.forEach((request) => {
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
notifyUser: request.requestedBy,
|
||||
subject: movie.title,
|
||||
message: movie.overview,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Media;
|
||||
|
||||
@@ -15,11 +15,11 @@ import { User } from './User';
|
||||
import Media from './Media';
|
||||
import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import TheMovieDb, { ANIME_KEYWORD_ID } from '../api/themoviedb';
|
||||
import RadarrAPI from '../api/radarr';
|
||||
import logger from '../logger';
|
||||
import SeasonRequest from './SeasonRequest';
|
||||
import SonarrAPI from '../api/sonarr';
|
||||
import SonarrAPI, { SonarrSeries } from '../api/sonarr';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
|
||||
@Entity()
|
||||
@@ -36,10 +36,18 @@ export class MediaRequest {
|
||||
})
|
||||
public media: Media;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.requests, { eager: true })
|
||||
@ManyToOne(() => User, (user) => user.requests, {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
public requestedBy: User;
|
||||
|
||||
@ManyToOne(() => User, { nullable: true, cascade: true, eager: true })
|
||||
@ManyToOne(() => User, {
|
||||
nullable: true,
|
||||
cascade: true,
|
||||
eager: true,
|
||||
onDelete: 'SET NULL',
|
||||
})
|
||||
public modifiedBy?: User;
|
||||
|
||||
@CreateDateColumn()
|
||||
@@ -61,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) {
|
||||
@@ -80,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,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',
|
||||
@@ -128,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 });
|
||||
@@ -136,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',
|
||||
@@ -151,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 },
|
||||
@@ -217,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(
|
||||
@@ -234,28 +251,71 @@ export class MediaRequest {
|
||||
return;
|
||||
}
|
||||
|
||||
const radarrSettings = settings.radarr.find(
|
||||
(radarr) => radarr.isDefault && !radarr.is4k
|
||||
);
|
||||
|
||||
if (!radarrSettings) {
|
||||
logger.info(
|
||||
'There is no default radarr configured. Did you set any of your Radarr servers as default?',
|
||||
{ label: 'Media Request' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tmdb = new TheMovieDb();
|
||||
const radarrSettings = settings.radarr[0];
|
||||
const radarr = new RadarrAPI({
|
||||
apiKey: radarrSettings.apiKey,
|
||||
url: `${radarrSettings.useSsl ? 'https' : 'http'}://${
|
||||
radarrSettings.hostname
|
||||
}:${radarrSettings.port}/api`,
|
||||
}:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`,
|
||||
});
|
||||
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(
|
||||
@@ -265,8 +325,6 @@ export class MediaRequest {
|
||||
}
|
||||
}
|
||||
|
||||
@AfterUpdate()
|
||||
@AfterInsert()
|
||||
private async _sendToSonarr() {
|
||||
if (
|
||||
this.status === MediaRequestStatus.APPROVED &&
|
||||
@@ -283,6 +341,18 @@ export class MediaRequest {
|
||||
return;
|
||||
}
|
||||
|
||||
const sonarrSettings = settings.sonarr.find(
|
||||
(sonarr) => sonarr.isDefault && !sonarr.is4k
|
||||
);
|
||||
|
||||
if (!sonarrSettings) {
|
||||
logger.info(
|
||||
'There is no default sonarr configured. Did you set any of your Sonarr servers as default?',
|
||||
{ label: 'Media Request' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: this.media.id },
|
||||
relations: ['requests'],
|
||||
@@ -293,12 +363,11 @@ export class MediaRequest {
|
||||
}
|
||||
|
||||
const tmdb = new TheMovieDb();
|
||||
const sonarrSettings = settings.sonarr[0];
|
||||
const sonarr = new SonarrAPI({
|
||||
apiKey: sonarrSettings.apiKey,
|
||||
url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${
|
||||
sonarrSettings.hostname
|
||||
}:${sonarrSettings.port}/api`,
|
||||
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`,
|
||||
});
|
||||
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||
|
||||
@@ -306,16 +375,67 @@ export class MediaRequest {
|
||||
throw new Error('Series was missing tvdb id');
|
||||
}
|
||||
|
||||
let seriesType: SonarrSeries['seriesType'] = 'standard';
|
||||
|
||||
// Change series type to anime if the anime keyword is present on tmdb
|
||||
if (
|
||||
series.keywords.results.some(
|
||||
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
)
|
||||
) {
|
||||
seriesType = 'anime';
|
||||
}
|
||||
|
||||
// Run this asynchronously so we don't wait for it on the UI side
|
||||
sonarr.addSeries({
|
||||
profileId: sonarrSettings.activeProfileId,
|
||||
rootFolderPath: sonarrSettings.activeDirectory,
|
||||
title: series.name,
|
||||
tvdbid: series.external_ids.tvdb_id,
|
||||
seasons: this.seasons.map((season) => season.seasonNumber),
|
||||
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(
|
||||
|
||||
@@ -5,15 +5,9 @@ import {
|
||||
ManyToOne,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
AfterInsert,
|
||||
AfterUpdate,
|
||||
getRepository,
|
||||
} from 'typeorm';
|
||||
import { MediaStatus } from '../constants/media';
|
||||
import Media from './Media';
|
||||
import logger from '../logger';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
|
||||
@Entity()
|
||||
class Season {
|
||||
@@ -38,60 +32,6 @@ class Season {
|
||||
constructor(init?: Partial<Season>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
@AfterInsert()
|
||||
@AfterUpdate()
|
||||
private async _sendSeasonAvailableNotification() {
|
||||
if (this.status === MediaStatus.AVAILABLE) {
|
||||
try {
|
||||
const lazyMedia = await this.media;
|
||||
const tmdb = new TheMovieDb();
|
||||
const mediaRepository = getRepository(Media);
|
||||
const media = await mediaRepository.findOneOrFail({
|
||||
where: { id: lazyMedia.id },
|
||||
relations: ['requests'],
|
||||
});
|
||||
|
||||
const availableSeasons = media.seasons.map(
|
||||
(season) => season.seasonNumber
|
||||
);
|
||||
|
||||
const request = media.requests.find(
|
||||
(request) =>
|
||||
// Check if the season is complete AND it contains the current season that was just marked available
|
||||
request.seasons.every((season) =>
|
||||
availableSeasons.includes(season.seasonNumber)
|
||||
) &&
|
||||
request.seasons.some(
|
||||
(season) => season.seasonNumber === this.seasonNumber
|
||||
)
|
||||
);
|
||||
|
||||
if (request) {
|
||||
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
subject: tv.name,
|
||||
message: tv.overview,
|
||||
notifyUser: request.requestedBy,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||
extra: [
|
||||
{
|
||||
name: 'Seasons',
|
||||
value: request.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong sending season available notice', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Season;
|
||||
|
||||
@@ -9,7 +9,7 @@ import session, { Store } from 'express-session';
|
||||
import { TypeormStore } from 'connect-typeorm/out';
|
||||
import YAML from 'yamljs';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import { OpenApiValidator } from 'express-openapi-validator';
|
||||
import * as OpenApiValidator from 'express-openapi-validator';
|
||||
import { Session } from './entity/Session';
|
||||
import { getSettings } from './lib/settings';
|
||||
import logger from './logger';
|
||||
@@ -17,9 +17,14 @@ 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';
|
||||
import PushoverAgent from './lib/notifications/agents/pushover';
|
||||
|
||||
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
|
||||
|
||||
logger.info(`Starting Overseerr version ${getAppVersion()}`);
|
||||
const dev = process.env.NODE_ENV !== 'production';
|
||||
const app = next({ dev });
|
||||
const handle = app.getRequestHandler();
|
||||
@@ -27,12 +32,26 @@ 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(),
|
||||
new PushoverAgent(),
|
||||
]);
|
||||
|
||||
// Start Jobs
|
||||
startJobs();
|
||||
@@ -61,11 +80,12 @@ app
|
||||
);
|
||||
const apiDocs = YAML.load(API_SPEC_PATH);
|
||||
server.use('/api-docs', swaggerUi.serve, swaggerUi.setup(apiDocs));
|
||||
await new OpenApiValidator({
|
||||
apiSpec: API_SPEC_PATH,
|
||||
validateRequests: true,
|
||||
validateResponses: true,
|
||||
}).install(server);
|
||||
server.use(
|
||||
OpenApiValidator.middleware({
|
||||
apiSpec: API_SPEC_PATH,
|
||||
validateRequests: true,
|
||||
})
|
||||
);
|
||||
/**
|
||||
* This is a workaround to convert dates to strings before they are validated by
|
||||
* OpenAPI validator. Otherwise, they are treated as objects instead of strings
|
||||
@@ -97,12 +117,21 @@ app
|
||||
}
|
||||
);
|
||||
|
||||
const port = Number(process.env.PORT) || 3000;
|
||||
server.listen(port, () => {
|
||||
logger.info(`Server ready on port ${port}`, {
|
||||
label: 'SERVER',
|
||||
const port = Number(process.env.PORT) || 5055;
|
||||
const host = process.env.HOST;
|
||||
if (host) {
|
||||
server.listen(port, host, () => {
|
||||
logger.info(`Server ready on ${host} port ${port}`, {
|
||||
label: 'Server',
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
server.listen(port, () => {
|
||||
logger.info(`Server ready on port ${port}`, {
|
||||
label: 'Server',
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error(err.stack);
|
||||
|
||||
7
server/interfaces/api/personInterfaces.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { PersonCreditCast, PersonCreditCrew } from '../../models/Person';
|
||||
|
||||
export interface PersonCombinedCreditsResponse {
|
||||
id: number;
|
||||
cast: PersonCreditCast[];
|
||||
crew: PersonCreditCrew[];
|
||||
}
|
||||
6
server/interfaces/api/settingsInterfaces.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface SettingsAboutResponse {
|
||||
version: string;
|
||||
totalRequests: number;
|
||||
totalMediaItems: number;
|
||||
tz?: string;
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
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';
|
||||
import { getSettings, Library } from '../../lib/settings';
|
||||
import Season from '../../entity/Season';
|
||||
import { uniqWith } from 'lodash';
|
||||
|
||||
const BUNDLE_SIZE = 10;
|
||||
const BUNDLE_SIZE = 20;
|
||||
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:\/\//);
|
||||
|
||||
@@ -39,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;
|
||||
@@ -51,45 +56,69 @@ class JobPlexSync {
|
||||
|
||||
private async processMovie(plexitem: PlexLibraryItem) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
if (plexitem.guid.match(plexRegex)) {
|
||||
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
|
||||
const newMedia = new Media();
|
||||
try {
|
||||
if (plexitem.guid.match(plexRegex)) {
|
||||
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
|
||||
const newMedia = new Media();
|
||||
|
||||
metadata.Guid.forEach((ref) => {
|
||||
if (ref.id.match(imdbRegex)) {
|
||||
newMedia.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined;
|
||||
} else if (ref.id.match(tmdbRegex)) {
|
||||
const tmdbMatch = ref.id.match(tmdbRegex)?.[1];
|
||||
newMedia.tmdbId = Number(tmdbMatch);
|
||||
if (!metadata.Guid) {
|
||||
logger.debug('No Guid metadata for this title. Skipping', {
|
||||
label: 'Plex Sync',
|
||||
ratingKey: plexitem.ratingKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const existing = await this.getExisting(newMedia.tmdbId);
|
||||
|
||||
if (existing && existing.status === MediaStatus.AVAILABLE) {
|
||||
this.log(`Title exists and is already available ${metadata.title}`);
|
||||
} else if (existing && existing.status !== MediaStatus.AVAILABLE) {
|
||||
existing.status = MediaStatus.AVAILABLE;
|
||||
mediaRepository.save(existing);
|
||||
this.log(
|
||||
`Request for ${metadata.title} exists. Setting status AVAILABLE`,
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
newMedia.status = MediaStatus.AVAILABLE;
|
||||
newMedia.mediaType = MediaType.MOVIE;
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${plexitem.title}`);
|
||||
}
|
||||
} else {
|
||||
const matchedid = plexitem.guid.match(/imdb:\/\/(tt[0-9]+)/);
|
||||
|
||||
if (matchedid?.[1]) {
|
||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
||||
imdbId: matchedid[1],
|
||||
metadata.Guid.forEach((ref) => {
|
||||
if (ref.id.match(imdbRegex)) {
|
||||
newMedia.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined;
|
||||
} else if (ref.id.match(tmdbRegex)) {
|
||||
const tmdbMatch = ref.id.match(tmdbRegex)?.[1];
|
||||
newMedia.tmdbId = Number(tmdbMatch);
|
||||
}
|
||||
});
|
||||
|
||||
const existing = await this.getExisting(tmdbMovie.id);
|
||||
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}`);
|
||||
} else if (existing && existing.status !== MediaStatus.AVAILABLE) {
|
||||
existing.status = MediaStatus.AVAILABLE;
|
||||
mediaRepository.save(existing);
|
||||
this.log(
|
||||
`Request for ${metadata.title} exists. Setting status AVAILABLE`,
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
newMedia.status = MediaStatus.AVAILABLE;
|
||||
newMedia.mediaType = MediaType.MOVIE;
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${plexitem.title}`);
|
||||
}
|
||||
} else {
|
||||
let tmdbMovieId: number | undefined;
|
||||
let tmdbMovie: TmdbMovieDetails | undefined;
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -99,7 +128,12 @@ class JobPlexSync {
|
||||
`Request for ${plexitem.title} exists. Setting status AVAILABLE`,
|
||||
'info'
|
||||
);
|
||||
} else if (tmdbMovie) {
|
||||
} 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;
|
||||
@@ -109,6 +143,15 @@ class JobPlexSync {
|
||||
this.log(`Saved ${tmdbMovie.title}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.log(
|
||||
`Failed to process plex item. ratingKey: ${plexitem.ratingKey}`,
|
||||
'error',
|
||||
{
|
||||
errorMessage: e.message,
|
||||
plexitem,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +162,9 @@ class JobPlexSync {
|
||||
|
||||
try {
|
||||
const metadata = await this.plexClient.getMetadata(
|
||||
plexitem.parentRatingKey ?? plexitem.ratingKey,
|
||||
plexitem.grandparentRatingKey ??
|
||||
plexitem.parentRatingKey ??
|
||||
plexitem.ratingKey,
|
||||
{ includeChildren: true }
|
||||
);
|
||||
if (metadata.guid.match(tvdbRegex)) {
|
||||
@@ -142,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
|
||||
@@ -198,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;
|
||||
@@ -222,9 +290,15 @@ class JobPlexSync {
|
||||
} catch (e) {
|
||||
this.log(
|
||||
`Failed to process plex item. ratingKey: ${
|
||||
plexitem.parentRatingKey ?? plexitem.ratingKey
|
||||
plexitem.grandparentRatingKey ??
|
||||
plexitem.parentRatingKey ??
|
||||
plexitem.ratingKey
|
||||
}`,
|
||||
'error'
|
||||
'error',
|
||||
{
|
||||
errorMessage: e.message,
|
||||
plexitem,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -234,7 +308,11 @@ class JobPlexSync {
|
||||
slicedItems.map(async (plexitem) => {
|
||||
if (plexitem.type === 'movie') {
|
||||
await this.processMovie(plexitem);
|
||||
} else if (plexitem.type === 'show') {
|
||||
} else if (
|
||||
plexitem.type === 'show' ||
|
||||
plexitem.type === 'episode' ||
|
||||
plexitem.type === 'season'
|
||||
) {
|
||||
await this.processShow(plexitem);
|
||||
}
|
||||
})
|
||||
@@ -253,23 +331,24 @@ class JobPlexSync {
|
||||
this.progress = start;
|
||||
await this.processItems(slicedItems);
|
||||
|
||||
await new Promise((resolve) =>
|
||||
await new Promise<void>((resolve) =>
|
||||
setTimeout(async () => {
|
||||
await this.loop({
|
||||
start: start + BUNDLE_SIZE,
|
||||
end: end + BUNDLE_SIZE,
|
||||
});
|
||||
resolve();
|
||||
}, 5000)
|
||||
}, UPDATE_RATE)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private log(
|
||||
message: string,
|
||||
level: 'info' | 'error' | 'debug' = 'debug'
|
||||
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
|
||||
optional?: Record<string, unknown>
|
||||
): void {
|
||||
logger[level](message, { label: 'Plex Sync' });
|
||||
logger[level](message, { label: 'Plex Sync', ...optional });
|
||||
}
|
||||
|
||||
public async run(): Promise<void> {
|
||||
@@ -277,26 +356,50 @@ class JobPlexSync {
|
||||
if (!this.running) {
|
||||
this.running = true;
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOneOrFail({
|
||||
const admin = await userRepository.findOne({
|
||||
select: ['id', 'plexToken'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
|
||||
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
|
||||
if (this.isRecentOnly) {
|
||||
this.currentLibrary = {
|
||||
id: '0',
|
||||
name: 'Recently Added',
|
||||
enabled: true,
|
||||
};
|
||||
this.log(`Beginning to process recently added`, 'info');
|
||||
this.items = await this.plexClient.getRecentlyAdded();
|
||||
await this.loop();
|
||||
} else {
|
||||
this.libraries = settings.plex.libraries.filter(
|
||||
(library) => library.enabled
|
||||
);
|
||||
if (!admin) {
|
||||
return this.log('No admin configured. Plex sync skipped.', 'warn');
|
||||
}
|
||||
|
||||
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
|
||||
|
||||
this.libraries = settings.plex.libraries.filter(
|
||||
(library) => library.enabled
|
||||
);
|
||||
|
||||
if (this.isRecentOnly) {
|
||||
for (const library of this.libraries) {
|
||||
this.currentLibrary = library;
|
||||
this.log(
|
||||
`Beginning to process recently added for library: ${library.name}`,
|
||||
'info'
|
||||
);
|
||||
const libraryItems = await this.plexClient.getRecentlyAdded(
|
||||
library.id
|
||||
);
|
||||
|
||||
// Bundle items up by rating keys
|
||||
this.items = uniqWith(libraryItems, (mediaA, mediaB) => {
|
||||
if (mediaA.grandparentRatingKey && mediaB.grandparentRatingKey) {
|
||||
return (
|
||||
mediaA.grandparentRatingKey === mediaB.grandparentRatingKey
|
||||
);
|
||||
}
|
||||
|
||||
if (mediaA.parentRatingKey && mediaB.parentRatingKey) {
|
||||
return mediaA.parentRatingKey === mediaB.parentRatingKey;
|
||||
}
|
||||
|
||||
return mediaA.ratingKey === mediaB.ratingKey;
|
||||
});
|
||||
|
||||
await this.loop();
|
||||
}
|
||||
} else {
|
||||
for (const library of this.libraries) {
|
||||
this.currentLibrary = library;
|
||||
this.log(`Beginning to process library: ${library.name}`, 'info');
|
||||
|
||||
@@ -13,17 +13,17 @@ export const startJobs = (): void => {
|
||||
// Run recently added plex sync every 5 minutes
|
||||
scheduledJobs.push({
|
||||
name: 'Plex Recently Added Sync',
|
||||
job: schedule.scheduleJob('0 */10 * * * *', () => {
|
||||
job: schedule.scheduleJob('0 */5 * * * *', () => {
|
||||
logger.info('Starting scheduled job: Plex Recently Added Sync', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
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 */6 * * *', () => {
|
||||
job: schedule.scheduleJob('0 0 3 * * *', () => {
|
||||
logger.info('Starting scheduled job: Plex Full Sync', { label: 'Jobs' });
|
||||
jobPlexFullSync.run();
|
||||
}),
|
||||
|
||||
@@ -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,24 +36,35 @@ 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,
|
||||
auth: {
|
||||
user: emailSettings.authUser,
|
||||
pass: emailSettings.authPass,
|
||||
},
|
||||
tls: emailSettings.allowSelfSigned
|
||||
? {
|
||||
rejectUnauthorized: false,
|
||||
}
|
||||
: undefined,
|
||||
auth:
|
||||
emailSettings.authUser && emailSettings.authPass
|
||||
? {
|
||||
user: emailSettings.authUser,
|
||||
pass: emailSettings.authPass,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
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(),
|
||||
@@ -48,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();
|
||||
@@ -73,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',
|
||||
},
|
||||
});
|
||||
@@ -88,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'
|
||||
@@ -107,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',
|
||||
},
|
||||
});
|
||||
@@ -122,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'
|
||||
@@ -135,12 +214,15 @@ class EmailAgent implements NotificationAgent {
|
||||
to: payload.notifyUser.email,
|
||||
},
|
||||
locals: {
|
||||
body: 'Your requsested media is now available!',
|
||||
body: 'Your requested media is now available!',
|
||||
mediaName: payload.subject,
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -154,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
|
||||
@@ -170,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;
|
||||
|
||||
122
server/lib/notifications/agents/pushover.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentPushover } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
|
||||
interface PushoverPayload {
|
||||
token: string;
|
||||
user: string;
|
||||
title: string;
|
||||
message: string;
|
||||
html: number;
|
||||
}
|
||||
|
||||
class PushoverAgent
|
||||
extends BaseAgent<NotificationAgentPushover>
|
||||
implements NotificationAgent {
|
||||
protected getSettings(): NotificationAgentPushover {
|
||||
if (this.settings) {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
|
||||
return settings.notifications.agents.pushover;
|
||||
}
|
||||
|
||||
public shouldSend(type: Notification): boolean {
|
||||
if (
|
||||
this.getSettings().enabled &&
|
||||
this.getSettings().options.accessToken &&
|
||||
this.getSettings().options.userToken &&
|
||||
hasNotificationType(type, this.getSettings().types)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private constructMessageDetails(
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): { title: string; message: string } {
|
||||
const settings = getSettings();
|
||||
let messageTitle = '';
|
||||
let message = '';
|
||||
|
||||
const title = payload.subject;
|
||||
const plot = payload.message;
|
||||
const user = payload.notifyUser.username;
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
messageTitle = 'New Request';
|
||||
message += `${title}\n\n`;
|
||||
message += `${plot}\n\n`;
|
||||
message += `<b>Requested By</b>\n${user}\n\n`;
|
||||
message += `<b>Status</b>\nPending Approval\n`;
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
messageTitle = 'Request Approved';
|
||||
message += `${title}\n\n`;
|
||||
message += `${plot}\n\n`;
|
||||
message += `<b>Requested By</b>\n${user}\n\n`;
|
||||
message += `<b>Status</b>\nProcessing Request\n`;
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
messageTitle = 'Now available!';
|
||||
message += `${title}\n\n`;
|
||||
message += `${plot}\n\n`;
|
||||
message += `<b>Requested By</b>\n${user}\n\n`;
|
||||
message += `<b>Status</b>\nAvailable\n`;
|
||||
break;
|
||||
case Notification.TEST_NOTIFICATION:
|
||||
messageTitle = 'Test Notification';
|
||||
message += `${title}\n\n`;
|
||||
message += `${plot}\n\n`;
|
||||
message += `<b>Requested By</b>\n${user}\n`;
|
||||
break;
|
||||
}
|
||||
|
||||
if (settings.main.applicationUrl && payload.media) {
|
||||
const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
|
||||
message += `<a href="${actionUrl}">Open in Overseerr</a>`;
|
||||
}
|
||||
|
||||
return { title: messageTitle, message };
|
||||
}
|
||||
|
||||
public async send(
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
logger.debug('Sending Pushover notification', { label: 'Notifications' });
|
||||
try {
|
||||
const endpoint = 'https://api.pushover.net/1/messages.json';
|
||||
|
||||
const { accessToken, userToken } = this.getSettings().options;
|
||||
|
||||
const { title, message } = this.constructMessageDetails(type, payload);
|
||||
|
||||
await axios.post(endpoint, {
|
||||
token: accessToken,
|
||||
user: userToken,
|
||||
title: title,
|
||||
message: message,
|
||||
html: 1,
|
||||
} as PushoverPayload);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Error sending Pushover notification', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PushoverAgent;
|
||||
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;
|
||||
@@ -14,6 +15,7 @@ export interface PlexSettings {
|
||||
machineId?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
useSsl?: boolean;
|
||||
libraries: Library[];
|
||||
}
|
||||
|
||||
@@ -46,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;
|
||||
@@ -71,12 +80,33 @@ interface NotificationAgentEmail extends NotificationAgent {
|
||||
secure: boolean;
|
||||
authUser?: string;
|
||||
authPass?: string;
|
||||
allowSelfSigned: boolean;
|
||||
senderName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NotificationAgentTelegram extends NotificationAgentConfig {
|
||||
options: {
|
||||
botAPI: string;
|
||||
chatId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NotificationAgentPushover extends NotificationAgentConfig {
|
||||
options: {
|
||||
accessToken: string;
|
||||
userToken: string;
|
||||
priority: number;
|
||||
sound: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface NotificationAgents {
|
||||
email: NotificationAgentEmail;
|
||||
discord: NotificationAgentDiscord;
|
||||
slack: NotificationAgentSlack;
|
||||
telegram: NotificationAgentTelegram;
|
||||
pushover: NotificationAgentPushover;
|
||||
}
|
||||
|
||||
interface NotificationSettings {
|
||||
@@ -100,15 +130,17 @@ class Settings {
|
||||
|
||||
constructor(initialSettings?: AllSettings) {
|
||||
this.data = {
|
||||
clientId: '',
|
||||
clientId: uuidv4(),
|
||||
main: {
|
||||
apiKey: '',
|
||||
applicationUrl: '',
|
||||
defaultPermissions: Permission.REQUEST,
|
||||
},
|
||||
plex: {
|
||||
name: '',
|
||||
ip: '127.0.0.1',
|
||||
port: 32400,
|
||||
useSsl: false,
|
||||
libraries: [],
|
||||
},
|
||||
radarr: [],
|
||||
@@ -124,8 +156,10 @@ class Settings {
|
||||
options: {
|
||||
emailFrom: '',
|
||||
smtpHost: '127.0.0.1',
|
||||
smtpPort: 465,
|
||||
smtpPort: 587,
|
||||
secure: false,
|
||||
allowSelfSigned: false,
|
||||
senderName: 'Overseerr',
|
||||
},
|
||||
},
|
||||
discord: {
|
||||
@@ -135,6 +169,31 @@ class Settings {
|
||||
webhookUrl: '',
|
||||
},
|
||||
},
|
||||
slack: {
|
||||
enabled: false,
|
||||
types: 0,
|
||||
options: {
|
||||
webhookUrl: '',
|
||||
},
|
||||
},
|
||||
telegram: {
|
||||
enabled: false,
|
||||
types: 0,
|
||||
options: {
|
||||
botAPI: '',
|
||||
chatId: '',
|
||||
},
|
||||
},
|
||||
pushover: {
|
||||
enabled: false,
|
||||
types: 0,
|
||||
options: {
|
||||
accessToken: '',
|
||||
userToken: '',
|
||||
priority: 0,
|
||||
sound: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -211,7 +270,7 @@ class Settings {
|
||||
}
|
||||
|
||||
private generateApiKey(): string {
|
||||
return Buffer.from(`${Date.now()}${this.clientId}`).toString('base64');
|
||||
return Buffer.from(`${Date.now()}${uuidv4()})`).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,10 +281,10 @@ class Settings {
|
||||
* @param overrideSettings If passed in, will override all existing settings with these
|
||||
* values
|
||||
*/
|
||||
public load(overrideSettings?: AllSettings): AllSettings {
|
||||
public load(overrideSettings?: AllSettings): Settings {
|
||||
if (overrideSettings) {
|
||||
this.data = overrideSettings;
|
||||
return this.data;
|
||||
return this;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(SETTINGS_PATH)) {
|
||||
@@ -237,7 +296,7 @@ class Settings {
|
||||
this.data = merge(this.data, JSON.parse(data));
|
||||
this.save();
|
||||
}
|
||||
return this.data;
|
||||
return this;
|
||||
}
|
||||
|
||||
public save(): void {
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
import { getRepository } from 'typeorm';
|
||||
import { User } from '../entity/User';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import { getSettings } from '../lib/settings';
|
||||
|
||||
export const checkUser: Middleware = async (req, _res, next) => {
|
||||
if (req.session?.userId) {
|
||||
const settings = getSettings();
|
||||
if (req.header('X-API-Key') === settings.main.apiKey) {
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
let userId = 1; // Work on original administrator account
|
||||
|
||||
// If a User ID is provided, we will act on that users behalf
|
||||
if (req.header('X-API-User')) {
|
||||
userId = Number(req.header('X-API-User'));
|
||||
}
|
||||
const user = await userRepository.findOne({ where: { id: userId } });
|
||||
|
||||
if (user) {
|
||||
req.user = user;
|
||||
}
|
||||
} else if (req.session?.userId) {
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
const user = await userRepository.findOne({
|
||||
@@ -24,7 +40,7 @@ export const isAuthenticated = (
|
||||
if (!req.user || !req.user.hasPermission(permissions ?? 0)) {
|
||||
res.status(403).json({
|
||||
status: 403,
|
||||
error: 'You do not have permisson to access this endpoint',
|
||||
error: 'You do not have permission to access this endpoint',
|
||||
});
|
||||
} else {
|
||||
next();
|
||||
|
||||
20
server/migration/1607928251245-DropImdbIdConstraint.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { MigrationInterface, QueryRunner, TableUnique } from 'typeorm';
|
||||
|
||||
export class DropImdbIdConstraint1607928251245 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropUniqueConstraint(
|
||||
'media',
|
||||
'UQ_7ff2d11f6a83cb52386eaebe74b'
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createUniqueConstraint(
|
||||
'media',
|
||||
new TableUnique({
|
||||
name: 'UQ_7ff2d11f6a83cb52386eaebe74b',
|
||||
columnNames: ['imdbId'],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUserRequestDeleteCascades1608219049304
|
||||
implements MigrationInterface {
|
||||
name = 'AddUserRequestDeleteCascades1608219049304';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "media_request"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "media_request"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_media_request" RENAME TO "media_request"`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "media_request" RENAME TO "temporary_media_request"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "temporary_media_request"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_media_request"`);
|
||||
}
|
||||
}
|
||||
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 {
|
||||
TmdbPersonCreditCrew,
|
||||
TmdbPersonDetail,
|
||||
} from '../api/themoviedb';
|
||||
import Media from '../entity/Media';
|
||||
|
||||
export interface PersonDetail {
|
||||
id: number;
|
||||
@@ -42,6 +43,7 @@ export interface PersonCredit {
|
||||
title: string;
|
||||
adult: boolean;
|
||||
releaseDate: string;
|
||||
mediaInfo?: Media;
|
||||
}
|
||||
|
||||
export interface PersonCreditCast extends PersonCredit {
|
||||
@@ -76,7 +78,8 @@ export const mapPersonDetails = (person: TmdbPersonDetail): PersonDetail => ({
|
||||
});
|
||||
|
||||
export const mapCastCredits = (
|
||||
cast: TmdbPersonCreditCast
|
||||
cast: TmdbPersonCreditCast,
|
||||
media?: Media
|
||||
): PersonCreditCast => ({
|
||||
id: cast.id,
|
||||
originalLanguage: cast.original_language,
|
||||
@@ -100,10 +103,12 @@ export const mapCastCredits = (
|
||||
adult: cast.adult,
|
||||
releaseDate: cast.release_date,
|
||||
character: cast.character,
|
||||
mediaInfo: media,
|
||||
});
|
||||
|
||||
export const mapCrewCredits = (
|
||||
crew: TmdbPersonCreditCrew
|
||||
crew: TmdbPersonCreditCrew,
|
||||
media?: Media
|
||||
): PersonCreditCrew => ({
|
||||
id: crew.id,
|
||||
originalLanguage: crew.original_language,
|
||||
@@ -128,4 +133,5 @@ export const mapCrewCredits = (
|
||||
releaseDate: crew.release_date,
|
||||
department: crew.department,
|
||||
job: crew.job,
|
||||
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);
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
mapCrew,
|
||||
ExternalIds,
|
||||
mapExternalIds,
|
||||
Keyword,
|
||||
mapVideos,
|
||||
} from './common';
|
||||
import {
|
||||
TmdbTvEpisodeResult,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
TmdbSeasonWithEpisodes,
|
||||
} from '../api/themoviedb';
|
||||
import type Media from '../entity/Media';
|
||||
import { Video } from './Movie';
|
||||
|
||||
interface Episode {
|
||||
id: number;
|
||||
@@ -45,6 +48,12 @@ export interface SeasonWithEpisodes extends Season {
|
||||
externalIds: ExternalIds;
|
||||
}
|
||||
|
||||
interface SpokenLanguage {
|
||||
englishName: string;
|
||||
iso_639_1: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TvDetails {
|
||||
id: number;
|
||||
backdropPath?: string;
|
||||
@@ -56,10 +65,11 @@ export interface TvDetails {
|
||||
profilePath?: string;
|
||||
}[];
|
||||
episodeRunTime: number[];
|
||||
firstAirDate: string;
|
||||
firstAirDate?: string;
|
||||
genres: Genre[];
|
||||
homepage: string;
|
||||
inProduction: boolean;
|
||||
relatedVideos?: Video[];
|
||||
languages: string[];
|
||||
lastAirDate: string;
|
||||
lastEpisodeToAir?: Episode;
|
||||
@@ -74,6 +84,7 @@ export interface TvDetails {
|
||||
overview: string;
|
||||
popularity: number;
|
||||
productionCompanies: ProductionCompany[];
|
||||
spokenLanguages: SpokenLanguage[];
|
||||
seasons: Season[];
|
||||
status: string;
|
||||
type: string;
|
||||
@@ -84,6 +95,7 @@ export interface TvDetails {
|
||||
crew: Crew[];
|
||||
};
|
||||
externalIds: ExternalIds;
|
||||
keywords: Keyword[];
|
||||
mediaInfo?: Media;
|
||||
}
|
||||
|
||||
@@ -136,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,
|
||||
@@ -161,6 +174,11 @@ export const mapTvDetails = (
|
||||
originCountry: company.origin_country,
|
||||
logoPath: company.logo_path,
|
||||
})),
|
||||
spokenLanguages: show.spoken_languages.map((language) => ({
|
||||
englishName: language.english_name,
|
||||
iso_639_1: language.iso_639_1,
|
||||
name: language.name,
|
||||
})),
|
||||
seasons: show.seasons.map(mapSeasonResult),
|
||||
status: show.status,
|
||||
type: show.type,
|
||||
@@ -179,5 +197,9 @@ export const mapTvDetails = (
|
||||
crew: show.credits.crew.map(mapCrew),
|
||||
},
|
||||
externalIds: mapExternalIds(show.external_ids),
|
||||
keywords: show.keywords.results.map((keyword) => ({
|
||||
id: keyword.id,
|
||||
name: keyword.name,
|
||||
})),
|
||||
mediaInfo: media,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -11,6 +15,11 @@ export interface ProductionCompany {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Keyword {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Genre {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -79,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();
|
||||
|
||||
@@ -14,7 +15,7 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => {
|
||||
return res.status(500).json({
|
||||
status: 500,
|
||||
error:
|
||||
'Requsted user endpoint withuot valid authenticated user in session',
|
||||
'Requested user endpoint without valid authenticated user in session',
|
||||
});
|
||||
}
|
||||
const user = await userRepository.findOneOrFail({
|
||||
@@ -24,7 +25,8 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => {
|
||||
return res.status(200).json(user.filter());
|
||||
});
|
||||
|
||||
authRoutes.post('/login', 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,37 +71,58 @@ authRoutes.post('/login', async (req, res) => {
|
||||
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,
|
||||
// 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' },
|
||||
});
|
||||
await userRepository.save(user);
|
||||
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;
|
||||
}
|
||||
|
||||
return res.status(200).json(user?.filter() ?? {});
|
||||
} catch (e) {
|
||||
logger.error(e.message, { label: 'Auth' });
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: 'Something went wrong. Is your auth token valid?' });
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Something went wrong. Is your auth token valid?',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
@@ -121,4 +123,36 @@ discoverRoutes.get('/trending', async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
discoverRoutes.get<{ keywordId: string }>(
|
||||
'/keyword/:keywordId/movies',
|
||||
async (req, res) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
const data = await tmdb.getMoviesByKeyword({
|
||||
keywordId: Number(req.params.keywordId),
|
||||
page: Number(req.query.page),
|
||||
language: req.query.language as string,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
page: data.page,
|
||||
totalPages: data.total_pages,
|
||||
totalResults: data.total_results,
|
||||
results: data.results.map((result) =>
|
||||
mapMovieResult(
|
||||
result,
|
||||
media.find(
|
||||
(req) =>
|
||||
req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default discoverRoutes;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -12,8 +12,8 @@ const mediaRoutes = Router();
|
||||
mediaRoutes.get('/', async (req, res, next) => {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const pageSize = Number(req.query.take) ?? 20;
|
||||
const skip = Number(req.query.skip) ?? 0;
|
||||
const pageSize = req.query.take ? Number(req.query.take) : 20;
|
||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||
|
||||
let statusFilter:
|
||||
| MediaStatus
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import Media from '../entity/Media';
|
||||
import logger from '../logger';
|
||||
import {
|
||||
mapCastCredits,
|
||||
@@ -32,9 +33,33 @@ personRoutes.get('/:id/combined_credits', async (req, res) => {
|
||||
language: req.query.language as string,
|
||||
});
|
||||
|
||||
const castMedia = await Media.getRelatedMedia(
|
||||
combinedCredits.cast.map((result) => result.id)
|
||||
);
|
||||
|
||||
const crewMedia = await Media.getRelatedMedia(
|
||||
combinedCredits.crew.map((result) => result.id)
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
cast: combinedCredits.cast.map((result) => mapCastCredits(result)),
|
||||
crew: combinedCredits.crew.map((result) => mapCrewCredits(result)),
|
||||
cast: combinedCredits.cast.map((result) =>
|
||||
mapCastCredits(
|
||||
result,
|
||||
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 && med.mediaType === result.media_type
|
||||
)
|
||||
)
|
||||
),
|
||||
id: combinedCredits.id,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,12 +15,12 @@ const requestRoutes = Router();
|
||||
requestRoutes.get('/', async (req, res, next) => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
try {
|
||||
const pageSize = Number(req.query.take) ?? 20;
|
||||
const skip = Number(req.query.skip) ?? 0;
|
||||
const pageSize = req.query.take ? Number(req.query.take) : 20;
|
||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||
|
||||
let statusFilter:
|
||||
| MediaRequestStatus
|
||||
| FindOperator<MediaRequestStatus>
|
||||
| FindOperator<string | MediaRequestStatus>
|
||||
| undefined = undefined;
|
||||
|
||||
switch (req.query.filter) {
|
||||
@@ -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,20 +16,39 @@ 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';
|
||||
import PushoverAgent from '../lib/notifications/agents/pushover';
|
||||
|
||||
const settingsRoutes = Router();
|
||||
|
||||
settingsRoutes.get('/main', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
if (!req.user?.hasPermission(Permission.ADMIN)) {
|
||||
return res.status(200).json({
|
||||
applicationUrl: settings.main.applicationUrl,
|
||||
} as Partial<MainSettings>);
|
||||
const filteredMainSettings = (
|
||||
user: User,
|
||||
main: MainSettings
|
||||
): Partial<MainSettings> => {
|
||||
if (!user?.hasPermission(Permission.ADMIN)) {
|
||||
return omit(main, 'apiKey');
|
||||
}
|
||||
|
||||
res.status(200).json(settings.main);
|
||||
return main;
|
||||
};
|
||||
|
||||
settingsRoutes.get('/main', (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
|
||||
if (!req.user) {
|
||||
return next({ status: 500, message: 'User missing from request' });
|
||||
}
|
||||
|
||||
res.status(200).json(filteredMainSettings(req.user, settings.main));
|
||||
});
|
||||
|
||||
settingsRoutes.post('/main', (req, res) => {
|
||||
@@ -41,6 +60,18 @@ settingsRoutes.post('/main', (req, res) => {
|
||||
return res.status(200).json(settings.main);
|
||||
});
|
||||
|
||||
settingsRoutes.get('/main/regenerate', (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const main = settings.regenerateApiKey();
|
||||
|
||||
if (!req.user) {
|
||||
return next({ status: 500, message: 'User missing from request' });
|
||||
}
|
||||
|
||||
return res.status(200).json(filteredMainSettings(req.user, main));
|
||||
});
|
||||
|
||||
settingsRoutes.get('/plex', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
@@ -91,17 +122,22 @@ settingsRoutes.get('/plex/library', async (req, res) => {
|
||||
|
||||
const libraries = await plexapi.getLibraries();
|
||||
|
||||
const newLibraries: Library[] = libraries.map((library) => {
|
||||
const existing = settings.plex.libraries.find(
|
||||
(l) => l.id === library.key
|
||||
);
|
||||
const newLibraries: Library[] = libraries
|
||||
// Remove libraries that are not movie or show
|
||||
.filter((library) => library.type === 'movie' || library.type === 'show')
|
||||
// Remove libraries that do not have a metadata agent set (usually personal video libraries)
|
||||
.filter((library) => library.agent !== 'com.plexapp.agents.none')
|
||||
.map((library) => {
|
||||
const existing = settings.plex.libraries.find(
|
||||
(l) => l.id === library.key && l.name === library.title
|
||||
);
|
||||
|
||||
return {
|
||||
id: library.key,
|
||||
name: library.title,
|
||||
enabled: existing?.enabled ?? false,
|
||||
};
|
||||
});
|
||||
return {
|
||||
id: library.key,
|
||||
name: library.title,
|
||||
enabled: existing?.enabled ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
settings.plex.libraries = newLibraries;
|
||||
}
|
||||
@@ -416,6 +452,127 @@ 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/pushover', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.pushover);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/notifications/pushover', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.pushover = req.body;
|
||||
settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.pushover);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/notifications/pushover/test', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
});
|
||||
}
|
||||
|
||||
const pushoverAgent = new PushoverAgent(req.body);
|
||||
pushoverAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
settingsRoutes.get('/notifications/email', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
@@ -431,4 +588,38 @@ 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);
|
||||
|
||||
const totalMediaItems = await mediaRepository.count();
|
||||
const totalRequests = await mediaRequestRepository.count();
|
||||
|
||||
return res.status(200).json({
|
||||
version: getAppVersion(),
|
||||
totalMediaItems,
|
||||
totalRequests,
|
||||
tz: process.env.TZ,
|
||||
} as SettingsAboutResponse);
|
||||
});
|
||||
|
||||
export default settingsRoutes;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { mapTvDetails, mapSeasonWithEpisodes } from '../models/Tv';
|
||||
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();
|
||||
|
||||
@@ -15,10 +17,14 @@ 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) {
|
||||
logger.error('Failed to get tv show', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
return next({ status: 404, message: 'TV Show does not exist' });
|
||||
}
|
||||
});
|
||||
@@ -55,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
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
@@ -81,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
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
@@ -101,7 +111,7 @@ tvRoutes.get('/:id/ratings', async (req, res, next) => {
|
||||
|
||||
const rtratings = await rtapi.getTVRatings(
|
||||
tv.name,
|
||||
Number(tv.first_air_date.slice(0, 4))
|
||||
tv.first_air_date ? Number(tv.first_air_date.slice(0, 4)) : undefined
|
||||
);
|
||||
|
||||
if (!rtratings) {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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();
|
||||
|
||||
@@ -94,13 +98,96 @@ router.delete<{ id: string }>('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
const user = await userRepository.findOneOrFail({
|
||||
const user = await userRepository.findOne({
|
||||
where: { id: Number(req.params.id) },
|
||||
relations: ['requests'],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return next({ status: 404, message: 'User not found' });
|
||||
}
|
||||
|
||||
if (user.id === 1) {
|
||||
return next({ status: 405, message: 'This account cannot be deleted.' });
|
||||
}
|
||||
|
||||
if (user.hasPermission(Permission.ADMIN)) {
|
||||
return next({
|
||||
status: 405,
|
||||
message: 'You cannot delete users with administrative privileges.',
|
||||
});
|
||||
}
|
||||
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
/**
|
||||
* Requests are usually deleted through a cascade constraint. Those however, do
|
||||
* not trigger the removal event so listeners to not run and the parent Media
|
||||
* will not be updated back to unknown for titles that were still pending. So
|
||||
* we manually remove all requests from the user here so the parent media's
|
||||
* properly reflect the change.
|
||||
*/
|
||||
await requestRepository.remove(user.requests);
|
||||
|
||||
await userRepository.delete(user.id);
|
||||
return res.status(200).json(user.filter());
|
||||
} catch (e) {
|
||||
next({ status: 404, message: 'User not found' });
|
||||
logger.error('Something went wrong deleting a user', {
|
||||
label: 'API',
|
||||
userId: req.params.id,
|
||||
errorMessage: e.message,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Something went wrong deleting the user',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
114
server/subscriber/MediaSubscriber.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
EntitySubscriberInterface,
|
||||
EventSubscriber,
|
||||
getRepository,
|
||||
UpdateEvent,
|
||||
} from 'typeorm';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import { MediaStatus, MediaType } from '../constants/media';
|
||||
import Media from '../entity/Media';
|
||||
import { MediaRequest } from '../entity/MediaRequest';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
|
||||
@EventSubscriber()
|
||||
export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
private async notifyAvailableMovie(entity: Media) {
|
||||
if (entity.status === MediaStatus.AVAILABLE) {
|
||||
if (entity.mediaType === MediaType.MOVIE) {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const relatedRequests = await requestRepository.find({
|
||||
where: { media: entity },
|
||||
});
|
||||
|
||||
if (relatedRequests.length > 0) {
|
||||
const tmdb = new TheMovieDb();
|
||||
const movie = await tmdb.getMovie({ movieId: entity.tmdbId });
|
||||
|
||||
relatedRequests.forEach((request) => {
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
notifyUser: request.requestedBy,
|
||||
subject: movie.title,
|
||||
message: movie.overview,
|
||||
media: entity,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyAvailableSeries(entity: Media, dbEntity: Media) {
|
||||
const newAvailableSeasons = entity.seasons
|
||||
.filter((season) => season.status === MediaStatus.AVAILABLE)
|
||||
.map((season) => season.seasonNumber);
|
||||
const oldAvailableSeasons = dbEntity.seasons
|
||||
.filter((season) => season.status === MediaStatus.AVAILABLE)
|
||||
.map((season) => season.seasonNumber);
|
||||
|
||||
const changedSeasons = newAvailableSeasons.filter(
|
||||
(seasonNumber) => !oldAvailableSeasons.includes(seasonNumber)
|
||||
);
|
||||
|
||||
if (changedSeasons.length > 0) {
|
||||
const tmdb = new TheMovieDb();
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const processedSeasons: number[] = [];
|
||||
|
||||
for (const changedSeasonNumber of changedSeasons) {
|
||||
const requests = await requestRepository.find({
|
||||
where: { media: entity },
|
||||
});
|
||||
const request = requests.find(
|
||||
(request) =>
|
||||
// Check if the season is complete AND it contains the current season that was just marked available
|
||||
request.seasons.every((season) =>
|
||||
newAvailableSeasons.includes(season.seasonNumber)
|
||||
) &&
|
||||
request.seasons.some(
|
||||
(season) => season.seasonNumber === changedSeasonNumber
|
||||
)
|
||||
);
|
||||
|
||||
if (request && !processedSeasons.includes(changedSeasonNumber)) {
|
||||
processedSeasons.push(
|
||||
...request.seasons.map((season) => season.seasonNumber)
|
||||
);
|
||||
const tv = await tmdb.getTvShow({ tvId: entity.tmdbId });
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
subject: tv.name,
|
||||
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',
|
||||
value: request.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public beforeUpdate(event: UpdateEvent<Media>): void {
|
||||
if (
|
||||
event.entity.mediaType === MediaType.MOVIE &&
|
||||
event.entity.status === MediaStatus.AVAILABLE
|
||||
) {
|
||||
this.notifyAvailableMovie(event.entity);
|
||||
}
|
||||
|
||||
if (
|
||||
event.entity.mediaType === MediaType.TV &&
|
||||
(event.entity.status === MediaStatus.AVAILABLE ||
|
||||
event.entity.status === MediaStatus.PARTIALLY_AVAILABLE)
|
||||
) {
|
||||
this.notifyAvailableSeries(event.entity, event.databaseEntity);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
|
||||
padding-bottom: 25px;\
|
||||
text-align: center;\
|
||||
')
|
||||
a(href='https://example.com' 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`
|
||||
3
server/types/plex-api.d.ts
vendored
@@ -2,8 +2,9 @@ declare module 'plex-api' {
|
||||
export default class PlexAPI {
|
||||
constructor(intiialOptions: {
|
||||
hostname: string;
|
||||
post: number;
|
||||
port: number;
|
||||
token?: string;
|
||||
https?: boolean;
|
||||
authenticator: {
|
||||
authenticate: (
|
||||
_plexApi: PlexAPI,
|
||||
|
||||
29
server/utils/appVersion.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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');
|
||||
|
||||
let finalVersion = version;
|
||||
|
||||
if (version === '0.1.0') {
|
||||
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 |
6
src/assets/extlogos/pushover.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="57 57 602 602" version="1.1">
|
||||
<g id="layer1" stroke="none" stroke-width="1" fill="rgb(31, 41, 55)" fill-rule="evenodd" transform="translate(58.964119, 58.887520)" opacity="0.91">
|
||||
<ellipse style="fill: rgb(255, 255, 255); fill-rule: evenodd; stroke: rgb(255, 255, 255); stroke-width: 0;" transform="matrix(-0.674571, 0.73821, -0.73821, -0.674571, 556.833239, 241.613465)" cx="216.308" cy="152.076" rx="296.855" ry="296.855"></ellipse>
|
||||
<path d="M 280.949 172.514 L 355.429 162.714 L 282.909 326.374 L 282.909 326.374 C 295.649 325.394 308.142 321.067 320.389 313.394 L 320.389 313.394 L 320.389 313.394 C 332.642 305.714 343.916 296.077 354.209 284.484 L 354.209 284.484 L 354.209 284.484 C 364.496 272.884 373.396 259.981 380.909 245.774 L 380.909 245.774 L 380.909 245.774 C 388.422 231.561 393.812 217.594 397.079 203.874 L 397.079 203.874 L 397.079 203.874 C 399.039 195.381 399.939 187.214 399.779 179.374 L 399.779 179.374 L 399.779 179.374 C 399.612 171.534 397.569 164.674 393.649 158.794 L 393.649 158.794 L 393.649 158.794 C 389.729 152.914 383.766 148.177 375.759 144.584 L 375.759 144.584 L 375.759 144.584 C 367.759 140.991 356.899 139.194 343.179 139.194 L 343.179 139.194 L 343.179 139.194 C 327.172 139.194 311.409 141.807 295.889 147.034 L 295.889 147.034 L 295.889 147.034 C 280.376 152.261 266.002 159.857 252.769 169.824 L 252.769 169.824 L 252.769 169.824 C 239.542 179.784 228.029 192.197 218.229 207.064 L 218.229 207.064 L 218.229 207.064 C 208.429 221.924 201.406 238.827 197.159 257.774 L 197.159 257.774 L 197.159 257.774 C 195.526 263.981 194.546 268.961 194.219 272.714 L 194.219 272.714 L 194.219 272.714 C 193.892 276.474 193.812 279.577 193.979 282.024 L 193.979 282.024 L 193.979 282.024 C 194.139 284.477 194.462 286.357 194.949 287.664 L 194.949 287.664 L 194.949 287.664 C 195.442 288.971 195.852 290.277 196.179 291.584 L 196.179 291.584 L 196.179 291.584 C 179.519 291.584 167.349 288.234 159.669 281.534 L 159.669 281.534 L 159.669 281.534 C 151.996 274.841 150.119 263.164 154.039 246.504 L 154.039 246.504 L 154.039 246.504 C 157.959 229.191 166.862 212.694 180.749 197.014 L 180.749 197.014 L 180.749 197.014 C 194.629 181.334 211.122 167.531 230.229 155.604 L 230.229 155.604 L 230.229 155.604 C 249.342 143.684 270.249 134.214 292.949 127.194 L 292.949 127.194 L 292.949 127.194 C 315.656 120.167 337.789 116.654 359.349 116.654 L 359.349 116.654 L 359.349 116.654 C 378.296 116.654 394.219 119.347 407.119 124.734 L 407.119 124.734 L 407.119 124.734 C 420.026 130.127 430.072 137.234 437.259 146.054 L 437.259 146.054 L 437.259 146.054 C 444.446 154.874 448.936 165.164 450.729 176.924 L 450.729 176.924 L 450.729 176.924 C 452.529 188.684 451.959 200.934 449.019 213.674 L 449.019 213.674 L 449.019 213.674 C 445.426 229.027 438.646 244.464 428.679 259.984 L 428.679 259.984 L 428.679 259.984 C 418.719 275.497 406.226 289.544 391.199 302.124 L 391.199 302.124 L 391.199 302.124 C 376.172 314.697 358.939 324.904 339.499 332.744 L 339.499 332.744 L 339.499 332.744 C 320.066 340.584 299.406 344.504 277.519 344.504 L 277.519 344.504 L 275.069 344.504 L 212.839 484.154 L 142.279 484.154 L 280.949 172.514 Z" transform="matrix(1, 0, 0, 1, 0, 0)" style="fill-rule: nonzero; white-space: pre;"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 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 |