Compare commits
218 Commits
feature/wa
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4aac491209 | ||
|
|
c2d4c61fae | ||
|
|
a2c25d5e4b | ||
|
|
19461574f2 | ||
|
|
6d82eee656 | ||
|
|
676a8c9764 | ||
|
|
e75351aa05 | ||
|
|
bafd93e952 | ||
|
|
1a6125c47d | ||
|
|
14cf43de26 | ||
|
|
b6fe5ac637 | ||
|
|
36283f214a | ||
|
|
d4ae554400 | ||
|
|
af0bd4ca8b | ||
|
|
107bce2e86 | ||
|
|
c749a0f9af | ||
|
|
d28e13187b | ||
|
|
2dd00481c2 | ||
|
|
5c60ffc567 | ||
|
|
8251c73ffa | ||
|
|
be047427df | ||
|
|
e297d25603 | ||
|
|
89287af096 | ||
|
|
3a593d9d76 | ||
|
|
a45fc86032 | ||
|
|
59dabed380 | ||
|
|
b40ba07a4d | ||
|
|
246887efa1 | ||
|
|
28a2c50495 | ||
|
|
c84ca43074 | ||
|
|
e2771a3011 | ||
|
|
3ea5076053 | ||
|
|
7bdd25e5a4 | ||
|
|
f6286359cf | ||
|
|
92507359b4 | ||
|
|
c484810f96 | ||
|
|
0c39057ca5 | ||
|
|
e62a078298 | ||
|
|
317110855e | ||
|
|
f7b4dfcac4 | ||
|
|
a686d31e4d | ||
|
|
cb63bf217b | ||
|
|
46e21c4e3e | ||
|
|
b4191f9c65 | ||
|
|
83b008c839 | ||
|
|
68c7b3650e | ||
|
|
2816c66300 | ||
|
|
01de972a8f | ||
|
|
a761b7dd35 | ||
|
|
d0836ce0ef | ||
|
|
2c3f533076 | ||
|
|
c1a47bd9de | ||
|
|
24d94ef6fd | ||
|
|
04fbd00d4a | ||
|
|
21231186d1 | ||
|
|
48f76662d5 | ||
|
|
df332cec84 | ||
|
|
d7fa35e066 | ||
|
|
f33eb862fd | ||
|
|
0a007ca805 | ||
|
|
24f268b6cb | ||
|
|
b8e3c07c47 | ||
|
|
aa84977680 | ||
|
|
e051b1dfea | ||
|
|
c27f96096a | ||
|
|
4bd87647d0 | ||
|
|
c1e10338c1 | ||
|
|
cd1cacad55 | ||
|
|
ac77b037d5 | ||
|
|
10eb69a7dc | ||
|
|
70b1540ae2 | ||
|
|
7522aa3174 | ||
|
|
77a33cb74d | ||
|
|
a4d07f5afa | ||
|
|
f5191aded6 | ||
|
|
2520d8f739 | ||
|
|
04980f93ab | ||
|
|
2a3213d706 | ||
|
|
c36a4ba2b8 | ||
|
|
ae3818304b | ||
|
|
b3882de893 | ||
|
|
af880a6c83 | ||
|
|
eb5502a16f | ||
|
|
50f06dabbf | ||
|
|
1e2c6f46ab | ||
|
|
dd1378cef5 | ||
|
|
e684456bba | ||
|
|
6bd3f015d6 | ||
|
|
2d97be0d6c | ||
|
|
966639df43 | ||
|
|
33e7691b94 | ||
|
|
d7b83d22ce | ||
|
|
b6eac0f364 | ||
|
|
c93467b3ac | ||
|
|
d954328911 | ||
|
|
3e43586acc | ||
|
|
7040da1334 | ||
|
|
9d10e6a88c | ||
|
|
8942eb8b7c | ||
|
|
51b05cd8fb | ||
|
|
374c78c989 | ||
|
|
507693881b | ||
|
|
f4a22dc437 | ||
|
|
5d1c6f7065 | ||
|
|
3db010b9ea | ||
|
|
fd219717c0 | ||
|
|
d328485161 | ||
|
|
da00d454e1 | ||
|
|
d7bfc73727 | ||
|
|
5940ff7f5f | ||
|
|
fcbca1722f | ||
|
|
19370f856c | ||
|
|
154f3e72ef | ||
|
|
6fd11cf425 | ||
|
|
1154156459 | ||
|
|
cb650745f6 | ||
|
|
3aefddd488 | ||
|
|
1bf0103422 | ||
|
|
a672b324ec | ||
|
|
a343f8ad91 | ||
|
|
c2a1a20a3b | ||
|
|
dd00e48f59 | ||
|
|
b5157010c4 | ||
|
|
7b6db50ae5 | ||
|
|
3ba6df1a41 | ||
|
|
2eebb7fd39 | ||
|
|
62e2de70bf | ||
|
|
0683f4f000 | ||
|
|
d23b2132de | ||
|
|
8bd10b5bf3 | ||
|
|
0d8b390b67 | ||
|
|
19b4dc424f | ||
|
|
7fef48df63 | ||
|
|
8220ea55ae | ||
|
|
4de4a1a52c | ||
|
|
042a1a950f | ||
|
|
421029ebab | ||
|
|
4e9be7a3f7 | ||
|
|
9d3446d370 | ||
|
|
2f680b4cec | ||
|
|
2179637d43 | ||
|
|
e084649878 | ||
|
|
edf5010659 | ||
|
|
93afead92e | ||
|
|
dd48d59b20 | ||
|
|
c4b16abc62 | ||
|
|
1a95d423f2 | ||
|
|
cd3574851a | ||
|
|
f14d9407d8 | ||
|
|
68223f4b1e | ||
|
|
76335ec8d3 | ||
|
|
3309f77aa4 | ||
|
|
6face8cc45 | ||
|
|
27feeea691 | ||
|
|
03853a1b91 | ||
|
|
357cab87ac | ||
|
|
76a7ceb758 | ||
|
|
9688acaa87 | ||
|
|
15e246929b | ||
|
|
07ec3efbca | ||
|
|
64aab6dd82 | ||
|
|
144bb84bdc | ||
|
|
76260f9b22 | ||
|
|
500cd1f872 | ||
|
|
bfe56c3470 | ||
|
|
1dfa9431a9 | ||
|
|
f5c2fc1c20 | ||
|
|
21d20fdfd6 | ||
|
|
cf96db90ad | ||
|
|
430b1ab871 | ||
|
|
7404d68143 | ||
|
|
16cb53f703 | ||
|
|
407af32d32 | ||
|
|
5c01313cc4 | ||
|
|
d8da5cbe9d | ||
|
|
3d458dd2fd | ||
|
|
e486623310 | ||
|
|
f20ba3fc2e | ||
|
|
30141f76e0 | ||
|
|
87825a0e05 | ||
|
|
99fc9a2da0 | ||
|
|
611ceeb5f4 | ||
|
|
14280c5437 | ||
|
|
867286996b | ||
|
|
03d5e56678 | ||
|
|
410ad0d4b4 | ||
|
|
23f93e311d | ||
|
|
2950cf4438 | ||
|
|
dbdecb1e0a | ||
|
|
833f52de56 | ||
|
|
889caaa733 | ||
|
|
4d56320870 | ||
|
|
1a0053221b | ||
|
|
b925857dfa | ||
|
|
c4aa08f5f0 | ||
|
|
5d73bc2238 | ||
|
|
095048d94a | ||
|
|
98028bf2f4 | ||
|
|
baf1ea95a3 | ||
|
|
23409e6f2f | ||
|
|
dd28200040 | ||
|
|
22360f3b87 | ||
|
|
815d709bcf | ||
|
|
8a2acb7f2b | ||
|
|
67f3a3829e | ||
|
|
f5e5016ca5 | ||
|
|
6e60a275c7 | ||
|
|
3b2633812b | ||
|
|
507227aa49 | ||
|
|
29ab178fb0 | ||
|
|
f5e6b620c1 | ||
|
|
0839718806 | ||
|
|
950b1712b7 | ||
|
|
43a9067976 | ||
|
|
c6a133d4e5 | ||
|
|
4b855b8114 | ||
|
|
6c0fd40877 | ||
|
|
301f2bf7ab |
@@ -737,6 +737,240 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"translation"
|
"translation"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Eclipseop",
|
||||||
|
"name": "Mackenzie",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/5846213?v=4",
|
||||||
|
"profile": "https://github.com/Eclipseop",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "s0up4200",
|
||||||
|
"name": "soup",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/18177310?v=4",
|
||||||
|
"profile": "https://github.com/s0up4200",
|
||||||
|
"contributions": [
|
||||||
|
"doc"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "ceptonit",
|
||||||
|
"name": "ceptonit",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/12678743?v=4",
|
||||||
|
"profile": "https://github.com/ceptonit",
|
||||||
|
"contributions": [
|
||||||
|
"doc"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "aedelbro",
|
||||||
|
"name": "aedelbro",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/36162221?v=4",
|
||||||
|
"profile": "https://github.com/aedelbro",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "lunks",
|
||||||
|
"name": "Pedro Nascimento",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/91118?v=4",
|
||||||
|
"profile": "http://twitter.com/lunks/",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "owenvoke",
|
||||||
|
"name": "Owen Voke",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/1899334?v=4",
|
||||||
|
"profile": "https://voke.dev",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Nimelrian",
|
||||||
|
"name": "Sebastian K",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/8960836?v=4",
|
||||||
|
"profile": "https://github.com/Nimelrian",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "jariz",
|
||||||
|
"name": "jariz",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/1415847?v=4",
|
||||||
|
"profile": "https://github.com/jariz",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Alexays",
|
||||||
|
"name": "Alex",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/13947260?v=4",
|
||||||
|
"profile": "https://arouillard.fr",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Zebebles",
|
||||||
|
"name": "Zeb Muller",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/11425451?v=4",
|
||||||
|
"profile": "https://github.com/Zebebles",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "SMores",
|
||||||
|
"name": "Shane Friedman",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/5354254?v=4",
|
||||||
|
"profile": "http://smoores.dev",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "IzaacJ",
|
||||||
|
"name": "Izaac Brånn",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/711323?v=4",
|
||||||
|
"profile": "https://izaacj.me",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "SalmanTariq",
|
||||||
|
"name": "Salman Tariq",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/13284494?v=4",
|
||||||
|
"profile": "https://github.com/SalmanTariq",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "andrew-kennedy",
|
||||||
|
"name": "Andrew Kennedy",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/2387159?v=4",
|
||||||
|
"profile": "https://github.com/andrew-kennedy",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Fallenbagel",
|
||||||
|
"name": "Fallenbagel",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/98979876?v=4",
|
||||||
|
"profile": "https://github.com/Fallenbagel",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "scorp200",
|
||||||
|
"name": "Anton K. (ai Doge)",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/9427639?v=4",
|
||||||
|
"profile": "http://aidoge.xyz",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "marcofaggian",
|
||||||
|
"name": "Marco Faggian",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/19221001?v=4",
|
||||||
|
"profile": "https://marcofaggian.com",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "nemchik",
|
||||||
|
"name": "Eric Nemchik",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/725456?v=4",
|
||||||
|
"profile": "http://nemchik.com/",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "RemiRigal",
|
||||||
|
"name": "RemiRigal",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/19256051?v=4",
|
||||||
|
"profile": "https://github.com/RemiRigal",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "j0srisk",
|
||||||
|
"name": "Joseph Risk",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4",
|
||||||
|
"profile": "http://josephrisk.com",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Loetwiek",
|
||||||
|
"name": "Loetwiek",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4",
|
||||||
|
"profile": "https://github.com/Loetwiek",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Fuochi",
|
||||||
|
"name": "Fuochi",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4",
|
||||||
|
"profile": "https://github.com/Fuochi",
|
||||||
|
"contributions": [
|
||||||
|
"doc"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "demrich",
|
||||||
|
"name": "David Emrich",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/30092389?v=4",
|
||||||
|
"profile": "https://github.com/demrich",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "maxnatamo",
|
||||||
|
"name": "Max T. Kristiansen",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/5898152?v=4",
|
||||||
|
"profile": "https://maxtrier.dk",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "DamsDev1",
|
||||||
|
"name": "Damien Fajole",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/60252259?v=4",
|
||||||
|
"profile": "https://damsdev.me",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "AhmedNSidd",
|
||||||
|
"name": "Ahmed Siddiqui",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/36286128?v=4",
|
||||||
|
"profile": "https://github.com/AhmedNSidd",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
||||||
@@ -745,5 +979,7 @@
|
|||||||
"projectOwner": "sct",
|
"projectOwner": "sct",
|
||||||
"repoType": "github",
|
"repoType": "github",
|
||||||
"repoHost": "https://github.com",
|
"repoHost": "https://github.com",
|
||||||
"skipCi": false
|
"skipCi": false,
|
||||||
|
"commitConvention": "angular",
|
||||||
|
"commitType": "docs"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ module.exports = {
|
|||||||
prefer: 'type-imports',
|
prefer: 'type-imports',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
'no-relative-import-paths/no-relative-import-paths': [
|
||||||
|
'error',
|
||||||
|
{ allowSameFolder: true },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
@@ -47,7 +51,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
plugins: ['jsx-a11y', 'react-hooks', 'formatjs'],
|
plugins: ['jsx-a11y', 'react-hooks', 'formatjs', 'no-relative-import-paths'],
|
||||||
settings: {
|
settings: {
|
||||||
react: {
|
react: {
|
||||||
pragma: 'React',
|
pragma: 'React',
|
||||||
|
|||||||
21
.gitattributes
vendored
21
.gitattributes
vendored
@@ -24,3 +24,24 @@
|
|||||||
*.woff binary
|
*.woff binary
|
||||||
*.pyc binary
|
*.pyc binary
|
||||||
*.pdf binary
|
*.pdf binary
|
||||||
|
|
||||||
|
#
|
||||||
|
## Theses files/directories should be excluded from git archives
|
||||||
|
#
|
||||||
|
|
||||||
|
.husky export-ignore
|
||||||
|
.vscode export-ignore
|
||||||
|
docs export-ignore
|
||||||
|
|
||||||
|
.git* export-ignore
|
||||||
|
*ignore export-ignore
|
||||||
|
*.md export-ignore
|
||||||
|
|
||||||
|
.all-contributorsrc export-ignore
|
||||||
|
.editorconfig export-ignore
|
||||||
|
Dockerfile.local export-ignore
|
||||||
|
docker-compose.yml export-ignore
|
||||||
|
stylelint.config.js export-ignore
|
||||||
|
|
||||||
|
public/os_logo_filled.png export-ignore
|
||||||
|
public/preview.jpg export-ignore
|
||||||
|
|||||||
10
.github/CODEOWNERS
vendored
10
.github/CODEOWNERS
vendored
@@ -1,10 +1,10 @@
|
|||||||
# Global code ownership
|
# Global code ownership
|
||||||
* @sct @TheCatLady @danshilm
|
* @sct @TheCatLady @danshilm @OwsleyJr
|
||||||
|
|
||||||
# Documentation
|
# Documentation
|
||||||
/.all-contributorsrc @TheCatLady @samwiseg0 @danshilm
|
/.all-contributorsrc @TheCatLady @samwiseg0 @danshilm @OwsleyJr
|
||||||
/*.md @TheCatLady @samwiseg0 @danshilm
|
/*.md @TheCatLady @samwiseg0 @danshilm @OwsleyJr
|
||||||
/docs/ @TheCatLady @samwiseg0 @danshilm
|
/docs/ @TheCatLady @samwiseg0 @danshilm @OwsleyJr
|
||||||
|
|
||||||
# Snap-related files
|
# Snap-related files
|
||||||
/.github/workflows/snap.yaml @samwiseg0
|
/.github/workflows/snap.yaml @samwiseg0
|
||||||
@@ -12,4 +12,4 @@
|
|||||||
|
|
||||||
# i18n locale files
|
# i18n locale files
|
||||||
/src/i18n/locale/ @sct @TheCatLady
|
/src/i18n/locale/ @sct @TheCatLady
|
||||||
/src/i18n/locale/en.json @sct @TheCatLady @danshilm
|
/src/i18n/locale/en.json @sct @TheCatLady @danshilm @OwsleyJr
|
||||||
|
|||||||
5
.github/holopin.yml
vendored
Normal file
5
.github/holopin.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
organization: overseerr
|
||||||
|
defaultSticker: clcyagj1j329008l468ya8pu2
|
||||||
|
stickers:
|
||||||
|
- id: clcyagj1j329008l468ya8pu2
|
||||||
|
alias: overseerr-contributor
|
||||||
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
@@ -12,8 +12,8 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
name: Lint & Test Build
|
name: Lint & Test Build
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
container: node:16.17-alpine
|
container: node:18.18-alpine
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
build_and_push:
|
build_and_push:
|
||||||
name: Build & Publish Docker Images
|
name: Build & Publish Docker Images
|
||||||
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, '[skip ci]')
|
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, '[skip ci]')
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
@@ -39,13 +39,6 @@ jobs:
|
|||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v2
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v2
|
||||||
- name: Cache Docker layers
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: /tmp/.buildx-cache
|
|
||||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-buildx-
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
@@ -71,21 +64,12 @@ jobs:
|
|||||||
sctx/overseerr:${{ github.sha }}
|
sctx/overseerr:${{ github.sha }}
|
||||||
ghcr.io/sct/overseerr:develop
|
ghcr.io/sct/overseerr:develop
|
||||||
ghcr.io/sct/overseerr:${{ github.sha }}
|
ghcr.io/sct/overseerr:${{ github.sha }}
|
||||||
cache-from: type=local,src=/tmp/.buildx-cache
|
|
||||||
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
|
||||||
- # Temporary fix
|
|
||||||
# https://github.com/docker/build-push-action/issues/252
|
|
||||||
# https://github.com/moby/buildkit/issues/1896
|
|
||||||
name: Move cache
|
|
||||||
run: |
|
|
||||||
rm -rf /tmp/.buildx-cache
|
|
||||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
|
||||||
|
|
||||||
discord:
|
discord:
|
||||||
name: Send Discord Notification
|
name: Send Discord Notification
|
||||||
needs: build_and_push
|
needs: build_and_push
|
||||||
if: always() && github.event_name != 'pull_request' && !contains(github.event.head_commit.message, '[skip ci]')
|
if: always() && github.event_name != 'pull_request' && !contains(github.event.head_commit.message, '[skip ci]')
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Get Build Job Status
|
- name: Get Build Job Status
|
||||||
uses: technote-space/workflow-conclusion-action@v3
|
uses: technote-space/workflow-conclusion-action@v3
|
||||||
@@ -94,9 +78,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
failures=(neutral, skipped, timed_out, action_required)
|
failures=(neutral, skipped, timed_out, action_required)
|
||||||
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||||
echo ::set-output name=status::failure
|
echo "status=failure" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
- name: Post Status to Discord
|
- name: Post Status to Discord
|
||||||
uses: sarisia/actions-status-discord@v1
|
uses: sarisia/actions-status-discord@v1
|
||||||
|
|||||||
41
.github/workflows/codeql.yml
vendored
Normal file
41
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: 'CodeQL'
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['develop']
|
||||||
|
pull_request:
|
||||||
|
branches: ['develop']
|
||||||
|
schedule:
|
||||||
|
- cron: '50 7 * * 5'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: [javascript]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v2
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
queries: +security-and-quality
|
||||||
|
|
||||||
|
- name: Autobuild
|
||||||
|
uses: github/codeql-action/autobuild@v2
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v2
|
||||||
|
with:
|
||||||
|
category: '/language:${{ matrix.language }}'
|
||||||
4
.github/workflows/preview.yml
vendored
4
.github/workflows/preview.yml
vendored
@@ -8,13 +8,13 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build_and_push:
|
build_and_push:
|
||||||
name: Build & Publish Docker Preview Images
|
name: Build & Publish Docker Preview Images
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
- name: Get the version
|
- name: Get the version
|
||||||
id: get_version
|
id: get_version
|
||||||
run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/}
|
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v2
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
|
|||||||
17
.github/workflows/release.yml
vendored
17
.github/workflows/release.yml
vendored
@@ -8,7 +8,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
semantic-release:
|
semantic-release:
|
||||||
name: Tag and release latest version
|
name: Tag and release latest version
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
build-snap:
|
build-snap:
|
||||||
name: Build Snap Package (${{ matrix.architecture }})
|
name: Build Snap Package (${{ matrix.architecture }})
|
||||||
needs: semantic-release
|
needs: semantic-release
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -67,9 +67,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
git fetch --prune --tags
|
git fetch --prune --tags
|
||||||
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
||||||
echo ::set-output name=RELEASE::stable
|
echo "RELEASE=stable" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
echo ::set-output name=RELEASE::edge
|
echo "RELEASE=edge" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
- name: Set Up QEMU
|
- name: Set Up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v2
|
||||||
@@ -91,8 +91,9 @@ jobs:
|
|||||||
snap: ${{ steps.build.outputs.snap }}
|
snap: ${{ steps.build.outputs.snap }}
|
||||||
- name: Publish Snap Package
|
- name: Publish Snap Package
|
||||||
uses: snapcore/action-publish@v1
|
uses: snapcore/action-publish@v1
|
||||||
|
env:
|
||||||
|
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
|
||||||
with:
|
with:
|
||||||
store_login: ${{ secrets.SNAP_LOGIN }}
|
|
||||||
snap: ${{ steps.build.outputs.snap }}
|
snap: ${{ steps.build.outputs.snap }}
|
||||||
release: ${{ steps.prepare.outputs.RELEASE }}
|
release: ${{ steps.prepare.outputs.RELEASE }}
|
||||||
|
|
||||||
@@ -100,7 +101,7 @@ jobs:
|
|||||||
name: Send Discord Notification
|
name: Send Discord Notification
|
||||||
needs: semantic-release
|
needs: semantic-release
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Get Build Job Status
|
- name: Get Build Job Status
|
||||||
uses: technote-space/workflow-conclusion-action@v3
|
uses: technote-space/workflow-conclusion-action@v3
|
||||||
@@ -109,9 +110,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
failures=(neutral, skipped, timed_out, action_required)
|
failures=(neutral, skipped, timed_out, action_required)
|
||||||
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||||
echo ::set-output name=status::failure
|
echo "status=failure" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
- name: Post Status to Discord
|
- name: Post Status to Discord
|
||||||
uses: sarisia/actions-status-discord@v1
|
uses: sarisia/actions-status-discord@v1
|
||||||
|
|||||||
27
.github/workflows/snap.yaml
vendored
27
.github/workflows/snap.yaml
vendored
@@ -8,7 +8,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
jobs:
|
jobs:
|
||||||
name: Job Check
|
name: Job Check
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||||
steps:
|
steps:
|
||||||
- name: Cancel Previous Runs
|
- name: Cancel Previous Runs
|
||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
build-snap:
|
build-snap:
|
||||||
name: Build Snap Package (${{ matrix.architecture }})
|
name: Build Snap Package (${{ matrix.architecture }})
|
||||||
needs: jobs
|
needs: jobs
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -29,27 +29,25 @@ jobs:
|
|||||||
- armhf
|
- armhf
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v3
|
||||||
- name: Prepare
|
- name: Prepare
|
||||||
id: prepare
|
id: prepare
|
||||||
run: |
|
run: |
|
||||||
git fetch --prune --unshallow --tags
|
git fetch --prune --unshallow --tags
|
||||||
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
||||||
echo ::set-output name=RELEASE::stable
|
echo "RELEASE=stable" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
echo ::set-output name=RELEASE::edge
|
echo "RELEASE=edge" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
- name: Set Up QEMU
|
- name: Set Up QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v2
|
||||||
with:
|
|
||||||
image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
|
|
||||||
- name: Build Snap Package
|
- name: Build Snap Package
|
||||||
uses: diddlesnaps/snapcraft-multiarch-action@v1
|
uses: diddlesnaps/snapcraft-multiarch-action@v1
|
||||||
id: build
|
id: build
|
||||||
with:
|
with:
|
||||||
architecture: ${{ matrix.architecture }}
|
architecture: ${{ matrix.architecture }}
|
||||||
- name: Upload Snap Package
|
- name: Upload Snap Package
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: overseerr-snap-package-${{ matrix.architecture }}
|
name: overseerr-snap-package-${{ matrix.architecture }}
|
||||||
path: ${{ steps.build.outputs.snap }}
|
path: ${{ steps.build.outputs.snap }}
|
||||||
@@ -59,8 +57,9 @@ jobs:
|
|||||||
snap: ${{ steps.build.outputs.snap }}
|
snap: ${{ steps.build.outputs.snap }}
|
||||||
- name: Publish Snap Package
|
- name: Publish Snap Package
|
||||||
uses: snapcore/action-publish@v1
|
uses: snapcore/action-publish@v1
|
||||||
|
env:
|
||||||
|
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
|
||||||
with:
|
with:
|
||||||
store_login: ${{ secrets.SNAP_LOGIN }}
|
|
||||||
snap: ${{ steps.build.outputs.snap }}
|
snap: ${{ steps.build.outputs.snap }}
|
||||||
release: ${{ steps.prepare.outputs.RELEASE }}
|
release: ${{ steps.prepare.outputs.RELEASE }}
|
||||||
|
|
||||||
@@ -68,18 +67,18 @@ jobs:
|
|||||||
name: Send Discord Notification
|
name: Send Discord Notification
|
||||||
needs: build-snap
|
needs: build-snap
|
||||||
if: always() && !contains(github.event.head_commit.message, '[skip ci]')
|
if: always() && !contains(github.event.head_commit.message, '[skip ci]')
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Get Build Job Status
|
- name: Get Build Job Status
|
||||||
uses: technote-space/workflow-conclusion-action@v2
|
uses: technote-space/workflow-conclusion-action@v3
|
||||||
- name: Combine Job Status
|
- name: Combine Job Status
|
||||||
id: status
|
id: status
|
||||||
run: |
|
run: |
|
||||||
failures=(neutral, skipped, timed_out, action_required)
|
failures=(neutral, skipped, timed_out, action_required)
|
||||||
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||||
echo ::set-output name=status::failure
|
echo "status=failure" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
- name: Post Status to Discord
|
- name: Post Status to Discord
|
||||||
uses: sarisia/actions-status-discord@v1
|
uses: sarisia/actions-status-discord@v1
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -64,3 +64,6 @@ cypress/screenshots
|
|||||||
|
|
||||||
# TS Build Info
|
# TS Build Info
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
# Config Cache Directory
|
||||||
|
config/cache
|
||||||
|
|||||||
5
.prettierrc.js
Normal file
5
.prettierrc.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: [require('./merged-prettier-plugin.js')],
|
||||||
|
singleQuote: true,
|
||||||
|
trailingComma: 'es5',
|
||||||
|
};
|
||||||
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
@@ -15,5 +15,9 @@
|
|||||||
"database": "./config/db/db.sqlite3"
|
"database": "./config/db/db.sqlite3"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true,
|
||||||
|
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||||
|
"files.associations": {
|
||||||
|
"globals.css": "tailwindcss"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
Dockerfile
13
Dockerfile
@@ -1,4 +1,4 @@
|
|||||||
FROM node:16.17-alpine AS BUILD_IMAGE
|
FROM node:18.18-alpine AS BUILD_IMAGE
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -7,10 +7,11 @@ ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
|
|||||||
|
|
||||||
RUN \
|
RUN \
|
||||||
case "${TARGETPLATFORM}" in \
|
case "${TARGETPLATFORM}" in \
|
||||||
'linux/arm64' | 'linux/arm/v7') \
|
'linux/arm64' | 'linux/arm/v7') \
|
||||||
apk add --no-cache python3 make g++ && \
|
apk update && \
|
||||||
ln -s /usr/bin/python3 /usr/bin/python \
|
apk add --no-cache python3 make g++ gcc libc6-compat bash && \
|
||||||
;; \
|
yarn global add node-gyp \
|
||||||
|
;; \
|
||||||
esac
|
esac
|
||||||
|
|
||||||
COPY package.json yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
@@ -33,7 +34,7 @@ RUN touch config/DOCKER
|
|||||||
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
||||||
|
|
||||||
|
|
||||||
FROM node:16.17-alpine
|
FROM node:18.18-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:16.17-alpine
|
FROM node:18.18-alpine
|
||||||
|
|
||||||
COPY . /app
|
COPY . /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
247
README.md
247
README.md
@@ -9,10 +9,9 @@
|
|||||||
<a href="https://discord.gg/overseerr"><img src="https://img.shields.io/discord/783137440809746482" alt="Discord"></a>
|
<a href="https://discord.gg/overseerr"><img src="https://img.shields.io/discord/783137440809746482" alt="Discord"></a>
|
||||||
<a href="https://hub.docker.com/r/sctx/overseerr"><img src="https://img.shields.io/docker/pulls/sctx/overseerr" alt="Docker pulls"></a>
|
<a href="https://hub.docker.com/r/sctx/overseerr"><img src="https://img.shields.io/docker/pulls/sctx/overseerr" alt="Docker pulls"></a>
|
||||||
<a href="https://hosted.weblate.org/engage/overseerr/"><img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
<a href="https://hosted.weblate.org/engage/overseerr/"><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>
|
|
||||||
<a href="https://github.com/sct/overseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr"></a>
|
<a href="https://github.com/sct/overseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr"></a>
|
||||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
<!-- 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-80-orange.svg"/></a>
|
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-106-orange.svg"/></a>
|
||||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -74,110 +73,146 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
|||||||
<!-- prettier-ignore-start -->
|
<!-- prettier-ignore-start -->
|
||||||
<!-- markdownlint-disable -->
|
<!-- markdownlint-disable -->
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tbody>
|
||||||
<td align="center"><a href="https://sct.dev"><img src="https://avatars1.githubusercontent.com/u/234213?v=4?s=100" 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>
|
<tr>
|
||||||
<td align="center"><a href="https://github.com/azoitos"><img src="https://avatars2.githubusercontent.com/u/26529049?v=4?s=100" 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" valign="top" width="14.28%"><a href="https://sct.dev"><img src="https://avatars1.githubusercontent.com/u/234213?v=4?s=100" width="100px;" alt="sct"/><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/OwsleyJr"><img src="https://avatars3.githubusercontent.com/u/8635678?v=4?s=100" 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" valign="top" width="14.28%"><a href="https://github.com/azoitos"><img src="https://avatars2.githubusercontent.com/u/26529049?v=4?s=100" width="100px;" alt="Alex Zoitos"/><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/Ahreluth"><img src="https://avatars2.githubusercontent.com/u/75682440?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ahreluth</b></sub></a><br /><a href="#translation-Ahreluth" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/OwsleyJr"><img src="https://avatars3.githubusercontent.com/u/8635678?v=4?s=100" width="100px;" alt="Brandon Cohen"/><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/KovalevArtem"><img src="https://avatars0.githubusercontent.com/u/36500228?v=4?s=100" width="100px;" alt=""/><br /><sub><b>KovalevArtem</b></sub></a><br /><a href="#translation-KovalevArtem" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Ahreluth"><img src="https://avatars2.githubusercontent.com/u/75682440?v=4?s=100" width="100px;" alt="Ahreluth"/><br /><sub><b>Ahreluth</b></sub></a><br /><a href="#translation-Ahreluth" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://github.com/GiyomuWeb"><img src="https://avatars0.githubusercontent.com/u/62489209?v=4?s=100" width="100px;" alt=""/><br /><sub><b>GiyomuWeb</b></sub></a><br /><a href="#translation-GiyomuWeb" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/KovalevArtem"><img src="https://avatars0.githubusercontent.com/u/36500228?v=4?s=100" width="100px;" alt="KovalevArtem"/><br /><sub><b>KovalevArtem</b></sub></a><br /><a href="#translation-KovalevArtem" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://github.com/angrycuban13"><img src="https://avatars3.githubusercontent.com/u/39564898?v=4?s=100" 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>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/GiyomuWeb"><img src="https://avatars0.githubusercontent.com/u/62489209?v=4?s=100" width="100px;" alt="GiyomuWeb"/><br /><sub><b>GiyomuWeb</b></sub></a><br /><a href="#translation-GiyomuWeb" title="Translation">🌍</a></td>
|
||||||
</tr>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/angrycuban13"><img src="https://avatars3.githubusercontent.com/u/39564898?v=4?s=100" width="100px;" alt="Angry Cuban"/><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?s=100" width="100px;" alt=""/><br /><sub><b>jvennik</b></sub></a><br /><a href="#translation-jvennik" title="Translation">🌍</a></td>
|
<tr>
|
||||||
<td align="center"><a href="https://github.com/darknessgp"><img src="https://avatars0.githubusercontent.com/u/1521243?v=4?s=100" 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" valign="top" width="14.28%"><a href="https://github.com/jvennik"><img src="https://avatars3.githubusercontent.com/u/6672637?v=4?s=100" width="100px;" alt="jvennik"/><br /><sub><b>jvennik</b></sub></a><br /><a href="#translation-jvennik" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://github.com/saltydk"><img src="https://avatars1.githubusercontent.com/u/6587950?v=4?s=100" 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" valign="top" width="14.28%"><a href="https://github.com/darknessgp"><img src="https://avatars0.githubusercontent.com/u/1521243?v=4?s=100" width="100px;" alt="darknessgp"/><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/Shutruk"><img src="https://avatars2.githubusercontent.com/u/9198633?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Shutruk</b></sub></a><br /><a href="#translation-Shutruk" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/saltydk"><img src="https://avatars1.githubusercontent.com/u/6587950?v=4?s=100" width="100px;" alt="salty"/><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/krystiancharubin"><img src="https://avatars2.githubusercontent.com/u/17775600?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Krystian Charubin</b></sub></a><br /><a href="#design-krystiancharubin" title="Design">🎨</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Shutruk"><img src="https://avatars2.githubusercontent.com/u/9198633?v=4?s=100" width="100px;" alt="Shutruk"/><br /><sub><b>Shutruk</b></sub></a><br /><a href="#translation-Shutruk" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://github.com/kieron"><img src="https://avatars2.githubusercontent.com/u/8655212?v=4?s=100" 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" valign="top" width="14.28%"><a href="https://github.com/krystiancharubin"><img src="https://avatars2.githubusercontent.com/u/17775600?v=4?s=100" width="100px;" alt="Krystian Charubin"/><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/samwiseg0"><img src="https://avatars1.githubusercontent.com/u/2241731?v=4?s=100" 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>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/kieron"><img src="https://avatars2.githubusercontent.com/u/8655212?v=4?s=100" width="100px;" alt="Kieron Boswell"/><br /><sub><b>Kieron Boswell</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=kieron" title="Code">💻</a></td>
|
||||||
</tr>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/samwiseg0"><img src="https://avatars1.githubusercontent.com/u/2241731?v=4?s=100" width="100px;" alt="samwiseg0"/><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?s=100" 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>
|
<tr>
|
||||||
<td align="center"><a href="https://github.com/mmozeiko"><img src="https://avatars3.githubusercontent.com/u/1665010?v=4?s=100" 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" valign="top" width="14.28%"><a href="https://github.com/ecelebi29"><img src="https://avatars2.githubusercontent.com/u/8337120?v=4?s=100" width="100px;" alt="ecelebi29"/><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/mazzetta86"><img src="https://avatars2.githubusercontent.com/u/45591560?v=4?s=100" width="100px;" alt=""/><br /><sub><b>mazzetta86</b></sub></a><br /><a href="#translation-mazzetta86" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mmozeiko"><img src="https://avatars3.githubusercontent.com/u/1665010?v=4?s=100" width="100px;" alt="Mārtiņš Možeiko"/><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/Panzer1119"><img src="https://avatars1.githubusercontent.com/u/23016343?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Paul Hagedorn</b></sub></a><br /><a href="#translation-Panzer1119" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mazzetta86"><img src="https://avatars2.githubusercontent.com/u/45591560?v=4?s=100" width="100px;" alt="mazzetta86"/><br /><sub><b>mazzetta86</b></sub></a><br /><a href="#translation-mazzetta86" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://github.com/Shagon94"><img src="https://avatars3.githubusercontent.com/u/9140783?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Shagon94</b></sub></a><br /><a href="#translation-Shagon94" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Panzer1119"><img src="https://avatars1.githubusercontent.com/u/23016343?v=4?s=100" width="100px;" alt="Paul Hagedorn"/><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/sebstrgg"><img src="https://avatars3.githubusercontent.com/u/27026694?v=4?s=100" width="100px;" alt=""/><br /><sub><b>sebstrgg</b></sub></a><br /><a href="#translation-sebstrgg" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Shagon94"><img src="https://avatars3.githubusercontent.com/u/9140783?v=4?s=100" width="100px;" alt="Shagon94"/><br /><sub><b>Shagon94</b></sub></a><br /><a href="#translation-Shagon94" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://github.com/danshilm"><img src="https://avatars2.githubusercontent.com/u/20923978?v=4?s=100" 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>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sebstrgg"><img src="https://avatars3.githubusercontent.com/u/27026694?v=4?s=100" width="100px;" alt="sebstrgg"/><br /><sub><b>sebstrgg</b></sub></a><br /><a href="#translation-sebstrgg" title="Translation">🌍</a></td>
|
||||||
</tr>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/danshilm"><img src="https://avatars2.githubusercontent.com/u/20923978?v=4?s=100" width="100px;" alt="Danshil Mungur"/><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?s=100" width="100px;" alt=""/><br /><sub><b>doob187</b></sub></a><br /><a href="#infra-doob187" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
<tr>
|
||||||
<td align="center"><a href="https://github.com/johnpyp"><img src="https://avatars2.githubusercontent.com/u/20625636?v=4?s=100" 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" valign="top" width="14.28%"><a href="https://github.com/doob187"><img src="https://avatars1.githubusercontent.com/u/60312740?v=4?s=100" width="100px;" alt="doob187"/><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/ankarhem"><img src="https://avatars1.githubusercontent.com/u/14110063?v=4?s=100" 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> <a href="#translation-ankarhem" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/johnpyp"><img src="https://avatars2.githubusercontent.com/u/20625636?v=4?s=100" width="100px;" alt="johnpyp"/><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/jayesh100"><img src="https://avatars1.githubusercontent.com/u/8022175?v=4?s=100" 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" valign="top" width="14.28%"><a href="https://github.com/ankarhem"><img src="https://avatars1.githubusercontent.com/u/14110063?v=4?s=100" width="100px;" alt="Jakob Ankarhem"/><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> <a href="#translation-ankarhem" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://github.com/flying-sausages"><img src="https://avatars1.githubusercontent.com/u/23618693?v=4?s=100" 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>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jayesh100"><img src="https://avatars1.githubusercontent.com/u/8022175?v=4?s=100" width="100px;" alt="Jayesh"/><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/hirenshah"><img src="https://avatars2.githubusercontent.com/u/418112?v=4?s=100" width="100px;" alt=""/><br /><sub><b>hirenshah</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=hirenshah" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/flying-sausages"><img src="https://avatars1.githubusercontent.com/u/23618693?v=4?s=100" width="100px;" alt="flying-sausages"/><br /><sub><b>flying-sausages</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=flying-sausages" title="Documentation">📖</a></td>
|
||||||
<td align="center"><a href="https://github.com/TheCatLady"><img src="https://avatars0.githubusercontent.com/u/52870424?v=4?s=100" width="100px;" alt=""/><br /><sub><b>TheCatLady</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=TheCatLady" title="Code">💻</a> <a href="#translation-TheCatLady" title="Translation">🌍</a> <a href="https://github.com/sct/overseerr/commits?author=TheCatLady" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/hirenshah"><img src="https://avatars2.githubusercontent.com/u/418112?v=4?s=100" width="100px;" alt="hirenshah"/><br /><sub><b>hirenshah</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=hirenshah" title="Documentation">📖</a></td>
|
||||||
</tr>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/TheCatLady"><img src="https://avatars0.githubusercontent.com/u/52870424?v=4?s=100" width="100px;" alt="TheCatLady"/><br /><sub><b>TheCatLady</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=TheCatLady" title="Code">💻</a> <a href="#translation-TheCatLady" title="Translation">🌍</a> <a href="https://github.com/sct/overseerr/commits?author=TheCatLady" title="Documentation">📖</a></td>
|
||||||
<tr>
|
</tr>
|
||||||
<td align="center"><a href="https://github.com/chriscpritchard"><img src="https://avatars1.githubusercontent.com/u/1839074?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Chris Pritchard</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=chriscpritchard" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=chriscpritchard" title="Documentation">📖</a></td>
|
<tr>
|
||||||
<td align="center"><a href="https://github.com/Tamberlox"><img src="https://avatars3.githubusercontent.com/u/56069014?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Tamberlox</b></sub></a><br /><a href="#translation-Tamberlox" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/chriscpritchard"><img src="https://avatars1.githubusercontent.com/u/1839074?v=4?s=100" width="100px;" alt="Chris Pritchard"/><br /><sub><b>Chris Pritchard</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=chriscpritchard" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=chriscpritchard" title="Documentation">📖</a></td>
|
||||||
<td align="center"><a href="https://hmnd.io"><img src="https://avatars.githubusercontent.com/u/12853597?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=hmnd" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Tamberlox"><img src="https://avatars3.githubusercontent.com/u/56069014?v=4?s=100" width="100px;" alt="Tamberlox"/><br /><sub><b>Tamberlox</b></sub></a><br /><a href="#translation-Tamberlox" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://www.douglas-parker.com"><img src="https://avatars.githubusercontent.com/u/18235822?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Douglas Parker</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=douglasparker" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://hmnd.io"><img src="https://avatars.githubusercontent.com/u/12853597?v=4?s=100" width="100px;" alt="David"/><br /><sub><b>David</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=hmnd" title="Code">💻</a></td>
|
||||||
<td align="center"><a href="https://github.com/dancarter"><img src="https://avatars.githubusercontent.com/u/4387516?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Daniel Carter</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=dancarter" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://www.douglas-parker.com"><img src="https://avatars.githubusercontent.com/u/18235822?v=4?s=100" width="100px;" alt="Douglas Parker"/><br /><sub><b>Douglas Parker</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=douglasparker" title="Documentation">📖</a></td>
|
||||||
<td align="center"><a href="https://nuro.dev"><img src="https://avatars.githubusercontent.com/u/4991309?v=4?s=100" width="100px;" alt=""/><br /><sub><b>nuro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=NuroDev" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dancarter"><img src="https://avatars.githubusercontent.com/u/4387516?v=4?s=100" width="100px;" alt="Daniel Carter"/><br /><sub><b>Daniel Carter</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=dancarter" title="Code">💻</a></td>
|
||||||
<td align="center"><a href="https://github.com/onedr0p"><img src="https://avatars.githubusercontent.com/u/213795?v=4?s=100" width="100px;" alt=""/><br /><sub><b>ᗪєνιη ᗷυнʟ</b></sub></a><br /><a href="#infra-onedr0p" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://nuro.dev"><img src="https://avatars.githubusercontent.com/u/4991309?v=4?s=100" width="100px;" alt="nuro"/><br /><sub><b>nuro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=NuroDev" title="Documentation">📖</a></td>
|
||||||
</tr>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/onedr0p"><img src="https://avatars.githubusercontent.com/u/213795?v=4?s=100" width="100px;" alt="ᗪєνιη ᗷυнʟ"/><br /><sub><b>ᗪєνιη ᗷυнʟ</b></sub></a><br /><a href="#infra-onedr0p" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
<tr>
|
</tr>
|
||||||
<td align="center"><a href="https://github.com/JonnyWong16"><img src="https://avatars.githubusercontent.com/u/9099342?v=4?s=100" width="100px;" alt=""/><br /><sub><b>JonnyWong16</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JonnyWong16" title="Documentation">📖</a></td>
|
<tr>
|
||||||
<td align="center"><a href="https://github.com/Roxedus"><img src="https://avatars.githubusercontent.com/u/7110194?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Roxedus</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Roxedus" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JonnyWong16"><img src="https://avatars.githubusercontent.com/u/9099342?v=4?s=100" width="100px;" alt="JonnyWong16"/><br /><sub><b>JonnyWong16</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JonnyWong16" title="Documentation">📖</a></td>
|
||||||
<td align="center"><a href="https://github.com/WoisWoi"><img src="https://avatars.githubusercontent.com/u/75491231?v=4?s=100" width="100px;" alt=""/><br /><sub><b>WoisWoi</b></sub></a><br /><a href="#translation-WoisWoi" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Roxedus"><img src="https://avatars.githubusercontent.com/u/7110194?v=4?s=100" width="100px;" alt="Roxedus"/><br /><sub><b>Roxedus</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Roxedus" title="Documentation">📖</a></td>
|
||||||
<td align="center"><a href="https://github.com/HubDuck"><img src="https://avatars.githubusercontent.com/u/77843475?v=4?s=100" width="100px;" alt=""/><br /><sub><b>HubDuck</b></sub></a><br /><a href="#translation-HubDuck" title="Translation">🌍</a> <a href="https://github.com/sct/overseerr/commits?author=HubDuck" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/WoisWoi"><img src="https://avatars.githubusercontent.com/u/75491231?v=4?s=100" width="100px;" alt="WoisWoi"/><br /><sub><b>WoisWoi</b></sub></a><br /><a href="#translation-WoisWoi" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://github.com/costaht"><img src="https://avatars.githubusercontent.com/u/50637431?v=4?s=100" width="100px;" alt=""/><br /><sub><b>costaht</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=costaht" title="Documentation">📖</a> <a href="#translation-costaht" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/HubDuck"><img src="https://avatars.githubusercontent.com/u/77843475?v=4?s=100" width="100px;" alt="HubDuck"/><br /><sub><b>HubDuck</b></sub></a><br /><a href="#translation-HubDuck" title="Translation">🌍</a> <a href="https://github.com/sct/overseerr/commits?author=HubDuck" title="Documentation">📖</a></td>
|
||||||
<td align="center"><a href="https://github.com/Shjosan"><img src="https://avatars.githubusercontent.com/u/20847626?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Shjosan</b></sub></a><br /><a href="#translation-Shjosan" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/costaht"><img src="https://avatars.githubusercontent.com/u/50637431?v=4?s=100" width="100px;" alt="costaht"/><br /><sub><b>costaht</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=costaht" title="Documentation">📖</a> <a href="#translation-costaht" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://github.com/kobaubarr"><img src="https://avatars.githubusercontent.com/u/28481522?v=4?s=100" width="100px;" alt=""/><br /><sub><b>kobaubarr</b></sub></a><br /><a href="#translation-kobaubarr" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Shjosan"><img src="https://avatars.githubusercontent.com/u/20847626?v=4?s=100" width="100px;" alt="Shjosan"/><br /><sub><b>Shjosan</b></sub></a><br /><a href="#translation-Shjosan" title="Translation">🌍</a></td>
|
||||||
</tr>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/kobaubarr"><img src="https://avatars.githubusercontent.com/u/28481522?v=4?s=100" width="100px;" alt="kobaubarr"/><br /><sub><b>kobaubarr</b></sub></a><br /><a href="#translation-kobaubarr" title="Translation">🌍</a></td>
|
||||||
<tr>
|
</tr>
|
||||||
<td align="center"><a href="https://github.com/notorius28"><img src="https://avatars.githubusercontent.com/u/1621513?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ricardo González</b></sub></a><br /><a href="#translation-notorius28" title="Translation">🌍</a></td>
|
<tr>
|
||||||
<td align="center"><a href="http://torkili.uz"><img src="https://avatars.githubusercontent.com/u/460764?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Torkil</b></sub></a><br /><a href="#translation-Torkiliuz" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/notorius28"><img src="https://avatars.githubusercontent.com/u/1621513?v=4?s=100" width="100px;" alt="Ricardo González"/><br /><sub><b>Ricardo González</b></sub></a><br /><a href="#translation-notorius28" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://www.jagandeepbrar.io"><img src="https://avatars.githubusercontent.com/u/3048295?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jagandeep Brar</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JagandeepBrar" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://torkili.uz"><img src="https://avatars.githubusercontent.com/u/460764?v=4?s=100" width="100px;" alt="Torkil"/><br /><sub><b>Torkil</b></sub></a><br /><a href="#translation-Torkiliuz" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="http://dtalens.com"><img src="https://avatars.githubusercontent.com/u/6631832?v=4?s=100" width="100px;" alt=""/><br /><sub><b>dtalens</b></sub></a><br /><a href="#translation-dtalens" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://www.jagandeepbrar.io"><img src="https://avatars.githubusercontent.com/u/3048295?v=4?s=100" width="100px;" alt="Jagandeep Brar"/><br /><sub><b>Jagandeep Brar</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JagandeepBrar" title="Documentation">📖</a></td>
|
||||||
<td align="center"><a href="https://github.com/acortelyou"><img src="https://avatars.githubusercontent.com/u/1689668?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alex Cortelyou</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=acortelyou" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://dtalens.com"><img src="https://avatars.githubusercontent.com/u/6631832?v=4?s=100" width="100px;" alt="dtalens"/><br /><sub><b>dtalens</b></sub></a><br /><a href="#translation-dtalens" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://nz.linkedin.com/in/jonocairns"><img src="https://avatars.githubusercontent.com/u/182836?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jono Cairns</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jonocairns" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/acortelyou"><img src="https://avatars.githubusercontent.com/u/1689668?v=4?s=100" width="100px;" alt="Alex Cortelyou"/><br /><sub><b>Alex Cortelyou</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=acortelyou" title="Code">💻</a></td>
|
||||||
<td align="center"><a href="https://scias.net/"><img src="https://avatars.githubusercontent.com/u/439655?v=4?s=100" width="100px;" alt=""/><br /><sub><b>DJScias</b></sub></a><br /><a href="#translation-DJScias" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://nz.linkedin.com/in/jonocairns"><img src="https://avatars.githubusercontent.com/u/182836?v=4?s=100" width="100px;" alt="Jono Cairns"/><br /><sub><b>Jono Cairns</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jonocairns" title="Code">💻</a></td>
|
||||||
</tr>
|
<td align="center" valign="top" width="14.28%"><a href="https://scias.net/"><img src="https://avatars.githubusercontent.com/u/439655?v=4?s=100" width="100px;" alt="DJScias"/><br /><sub><b>DJScias</b></sub></a><br /><a href="#translation-DJScias" title="Translation">🌍</a></td>
|
||||||
<tr>
|
</tr>
|
||||||
<td align="center"><a href="https://github.com/Dabu-dot"><img src="https://avatars.githubusercontent.com/u/52525576?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Dabu-dot</b></sub></a><br /><a href="#translation-Dabu-dot" title="Translation">🌍</a></td>
|
<tr>
|
||||||
<td align="center"><a href="https://github.com/Jabster28"><img src="https://avatars.githubusercontent.com/u/29015942?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jabster28</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Jabster28" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Dabu-dot"><img src="https://avatars.githubusercontent.com/u/52525576?v=4?s=100" width="100px;" alt="Dabu-dot"/><br /><sub><b>Dabu-dot</b></sub></a><br /><a href="#translation-Dabu-dot" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://github.com/littlerooster"><img src="https://avatars.githubusercontent.com/u/83890654?v=4?s=100" width="100px;" alt=""/><br /><sub><b>littlerooster</b></sub></a><br /><a href="#translation-littlerooster" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Jabster28"><img src="https://avatars.githubusercontent.com/u/29015942?v=4?s=100" width="100px;" alt="Jabster28"/><br /><sub><b>Jabster28</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Jabster28" title="Code">💻</a></td>
|
||||||
<td align="center"><a href="https://github.com/dphildebrandt"><img src="https://avatars.githubusercontent.com/u/154459?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Dustin Hildebrandt</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=dphildebrandt" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/littlerooster"><img src="https://avatars.githubusercontent.com/u/83890654?v=4?s=100" width="100px;" alt="littlerooster"/><br /><sub><b>littlerooster</b></sub></a><br /><a href="#translation-littlerooster" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://github.com/Generator"><img src="https://avatars.githubusercontent.com/u/44146?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Bruno Guerreiro</b></sub></a><br /><a href="#translation-Generator" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dphildebrandt"><img src="https://avatars.githubusercontent.com/u/154459?v=4?s=100" width="100px;" alt="Dustin Hildebrandt"/><br /><sub><b>Dustin Hildebrandt</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=dphildebrandt" title="Code">💻</a></td>
|
||||||
<td align="center"><a href="https://github.com/iceHtwoO"><img src="https://avatars.githubusercontent.com/u/27020492?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alexander Neuhäuser</b></sub></a><br /><a href="#translation-iceHtwoO" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Generator"><img src="https://avatars.githubusercontent.com/u/44146?v=4?s=100" width="100px;" alt="Bruno Guerreiro"/><br /><sub><b>Bruno Guerreiro</b></sub></a><br /><a href="#translation-Generator" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="http://www.unext.co.jp"><img src="https://avatars.githubusercontent.com/u/37431541?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Livio</b></sub></a><br /><a href="#design-liviokanone" title="Design">🎨</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/iceHtwoO"><img src="https://avatars.githubusercontent.com/u/27020492?v=4?s=100" width="100px;" alt="Alexander Neuhäuser"/><br /><sub><b>Alexander Neuhäuser</b></sub></a><br /><a href="#translation-iceHtwoO" title="Translation">🌍</a></td>
|
||||||
</tr>
|
<td align="center" valign="top" width="14.28%"><a href="http://www.unext.co.jp"><img src="https://avatars.githubusercontent.com/u/37431541?v=4?s=100" width="100px;" alt="Livio"/><br /><sub><b>Livio</b></sub></a><br /><a href="#design-liviokanone" title="Design">🎨</a></td>
|
||||||
<tr>
|
</tr>
|
||||||
<td align="center"><a href="https://github.com/tangentThought"><img src="https://avatars.githubusercontent.com/u/25516090?v=4?s=100" width="100px;" alt=""/><br /><sub><b>tangentThought</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=tangentThought" title="Code">💻</a></td>
|
<tr>
|
||||||
<td align="center"><a href="https://github.com/nicospz"><img src="https://avatars.githubusercontent.com/u/31373060?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Nicolás Espinoza</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=nicospz" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/tangentThought"><img src="https://avatars.githubusercontent.com/u/25516090?v=4?s=100" width="100px;" alt="tangentThought"/><br /><sub><b>tangentThought</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=tangentThought" title="Code">💻</a></td>
|
||||||
<td align="center"><a href="https://github.com/sootylunatic"><img src="https://avatars.githubusercontent.com/u/36486087?v=4?s=100" width="100px;" alt=""/><br /><sub><b>sootylunatic</b></sub></a><br /><a href="#translation-sootylunatic" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/nicospz"><img src="https://avatars.githubusercontent.com/u/31373060?v=4?s=100" width="100px;" alt="Nicolás Espinoza"/><br /><sub><b>Nicolás Espinoza</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=nicospz" title="Code">💻</a></td>
|
||||||
<td align="center"><a href="https://github.com/JoKerIsCraZy"><img src="https://avatars.githubusercontent.com/u/47474211?v=4?s=100" width="100px;" alt=""/><br /><sub><b>JoKerIsCraZy</b></sub></a><br /><a href="#translation-JoKerIsCraZy" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sootylunatic"><img src="https://avatars.githubusercontent.com/u/36486087?v=4?s=100" width="100px;" alt="sootylunatic"/><br /><sub><b>sootylunatic</b></sub></a><br /><a href="#translation-sootylunatic" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://daddie.dev"><img src="https://avatars.githubusercontent.com/u/33762262?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Daddie0</b></sub></a><br /><a href="#translation-GoByeBye" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JoKerIsCraZy"><img src="https://avatars.githubusercontent.com/u/47474211?v=4?s=100" width="100px;" alt="JoKerIsCraZy"/><br /><sub><b>JoKerIsCraZy</b></sub></a><br /><a href="#translation-JoKerIsCraZy" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="http://ungaro.me"><img src="https://avatars.githubusercontent.com/u/43807696?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simone</b></sub></a><br /><a href="#translation-Simoneu01" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://daddie.dev"><img src="https://avatars.githubusercontent.com/u/33762262?v=4?s=100" width="100px;" alt="Daddie0"/><br /><sub><b>Daddie0</b></sub></a><br /><a href="#translation-GoByeBye" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://github.com/adan89lion"><img src="https://avatars.githubusercontent.com/u/6585644?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Seohyun Joo</b></sub></a><br /><a href="#translation-adan89lion" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://ungaro.me"><img src="https://avatars.githubusercontent.com/u/43807696?v=4?s=100" width="100px;" alt="Simone"/><br /><sub><b>Simone</b></sub></a><br /><a href="#translation-Simoneu01" title="Translation">🌍</a></td>
|
||||||
</tr>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/adan89lion"><img src="https://avatars.githubusercontent.com/u/6585644?v=4?s=100" width="100px;" alt="Seohyun Joo"/><br /><sub><b>Seohyun Joo</b></sub></a><br /><a href="#translation-adan89lion" title="Translation">🌍</a></td>
|
||||||
<tr>
|
</tr>
|
||||||
<td align="center"><a href="https://github.com/ty4ko"><img src="https://avatars.githubusercontent.com/u/21213535?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sergey</b></sub></a><br /><a href="#translation-ty4ko" title="Translation">🌍</a></td>
|
<tr>
|
||||||
<td align="center"><a href="https://github.com/skafte1990"><img src="https://avatars.githubusercontent.com/u/31465453?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Shaaft</b></sub></a><br /><a href="#translation-skafte1990" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ty4ko"><img src="https://avatars.githubusercontent.com/u/21213535?v=4?s=100" width="100px;" alt="Sergey"/><br /><sub><b>Sergey</b></sub></a><br /><a href="#translation-ty4ko" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://github.com/sr093906"><img src="https://avatars.githubusercontent.com/u/8369201?v=4?s=100" width="100px;" alt=""/><br /><sub><b>sr093906</b></sub></a><br /><a href="#translation-sr093906" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/skafte1990"><img src="https://avatars.githubusercontent.com/u/31465453?v=4?s=100" width="100px;" alt="Shaaft"/><br /><sub><b>Shaaft</b></sub></a><br /><a href="#translation-skafte1990" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://github.com/Nackophilz"><img src="https://avatars.githubusercontent.com/u/61667226?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Nackophilz</b></sub></a><br /><a href="#translation-Nackophilz" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sr093906"><img src="https://avatars.githubusercontent.com/u/8369201?v=4?s=100" width="100px;" alt="sr093906"/><br /><sub><b>sr093906</b></sub></a><br /><a href="#translation-sr093906" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://github.com/schambers"><img src="https://avatars.githubusercontent.com/u/31563?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sean Chambers</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=schambers" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Nackophilz"><img src="https://avatars.githubusercontent.com/u/61667226?v=4?s=100" width="100px;" alt="Nackophilz"/><br /><sub><b>Nackophilz</b></sub></a><br /><a href="#translation-Nackophilz" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://github.com/deniscerri"><img src="https://avatars.githubusercontent.com/u/64997243?v=4?s=100" width="100px;" alt=""/><br /><sub><b>deniscerri</b></sub></a><br /><a href="#translation-deniscerri" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/schambers"><img src="https://avatars.githubusercontent.com/u/31563?v=4?s=100" width="100px;" alt="Sean Chambers"/><br /><sub><b>Sean Chambers</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=schambers" title="Code">💻</a></td>
|
||||||
<td align="center"><a href="https://github.com/tomgacz"><img src="https://avatars.githubusercontent.com/u/14138209?v=4?s=100" width="100px;" alt=""/><br /><sub><b>tomgacz</b></sub></a><br /><a href="#translation-tomgacz" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/deniscerri"><img src="https://avatars.githubusercontent.com/u/64997243?v=4?s=100" width="100px;" alt="deniscerri"/><br /><sub><b>deniscerri</b></sub></a><br /><a href="#translation-deniscerri" title="Translation">🌍</a></td>
|
||||||
</tr>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/tomgacz"><img src="https://avatars.githubusercontent.com/u/14138209?v=4?s=100" width="100px;" alt="tomgacz"/><br /><sub><b>tomgacz</b></sub></a><br /><a href="#translation-tomgacz" title="Translation">🌍</a></td>
|
||||||
<tr>
|
</tr>
|
||||||
<td align="center"><a href="https://github.com/Andersborrits"><img src="https://avatars.githubusercontent.com/u/29452218?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Andersborrits</b></sub></a><br /><a href="#translation-Andersborrits" title="Translation">🌍</a></td>
|
<tr>
|
||||||
<td align="center"><a href="http://maxentrouault.fr"><img src="https://avatars.githubusercontent.com/u/67283154?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Maxent</b></sub></a><br /><a href="#translation-Maxentr" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Andersborrits"><img src="https://avatars.githubusercontent.com/u/29452218?v=4?s=100" width="100px;" alt="Andersborrits"/><br /><sub><b>Andersborrits</b></sub></a><br /><a href="#translation-Andersborrits" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://github.com/sambartik"><img src="https://avatars.githubusercontent.com/u/63553146?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Samuel Bartík</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=sambartik" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://maxentrouault.fr"><img src="https://avatars.githubusercontent.com/u/67283154?v=4?s=100" width="100px;" alt="Maxent"/><br /><sub><b>Maxent</b></sub></a><br /><a href="#translation-Maxentr" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://github.com/frank-cywong"><img src="https://avatars.githubusercontent.com/u/90653148?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Chun Yeung Wong</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=frank-cywong" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sambartik"><img src="https://avatars.githubusercontent.com/u/63553146?v=4?s=100" width="100px;" alt="Samuel Bartík"/><br /><sub><b>Samuel Bartík</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=sambartik" title="Code">💻</a></td>
|
||||||
<td align="center"><a href="https://github.com/TheMeanCanEHdian"><img src="https://avatars.githubusercontent.com/u/16025103?v=4?s=100" width="100px;" alt=""/><br /><sub><b>TheMeanCanEHdian</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=TheMeanCanEHdian" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/frank-cywong"><img src="https://avatars.githubusercontent.com/u/90653148?v=4?s=100" width="100px;" alt="Chun Yeung Wong"/><br /><sub><b>Chun Yeung Wong</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=frank-cywong" title="Code">💻</a></td>
|
||||||
<td align="center"><a href="https://github.com/Gylesie"><img src="https://avatars.githubusercontent.com/u/86306812?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Gylesie</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Gylesie" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/TheMeanCanEHdian"><img src="https://avatars.githubusercontent.com/u/16025103?v=4?s=100" width="100px;" alt="TheMeanCanEHdian"/><br /><sub><b>TheMeanCanEHdian</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=TheMeanCanEHdian" title="Code">💻</a></td>
|
||||||
<td align="center"><a href="https://github.com/Fhd-pro"><img src="https://avatars.githubusercontent.com/u/82862079?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Fhd-pro</b></sub></a><br /><a href="#translation-Fhd-pro" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Gylesie"><img src="https://avatars.githubusercontent.com/u/86306812?v=4?s=100" width="100px;" alt="Gylesie"/><br /><sub><b>Gylesie</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Gylesie" title="Code">💻</a></td>
|
||||||
</tr>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fhd-pro"><img src="https://avatars.githubusercontent.com/u/82862079?v=4?s=100" width="100px;" alt="Fhd-pro"/><br /><sub><b>Fhd-pro</b></sub></a><br /><a href="#translation-Fhd-pro" title="Translation">🌍</a></td>
|
||||||
<tr>
|
</tr>
|
||||||
<td align="center"><a href="https://github.com/PovilasID"><img src="https://avatars.githubusercontent.com/u/396243?v=4?s=100" width="100px;" alt=""/><br /><sub><b>PovilasID</b></sub></a><br /><a href="#translation-PovilasID" title="Translation">🌍</a></td>
|
<tr>
|
||||||
<td align="center"><a href="https://github.com/byakurau"><img src="https://avatars.githubusercontent.com/u/1811683?v=4?s=100" width="100px;" alt=""/><br /><sub><b>byakurau</b></sub></a><br /><a href="#translation-byakurau" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/PovilasID"><img src="https://avatars.githubusercontent.com/u/396243?v=4?s=100" width="100px;" alt="PovilasID"/><br /><sub><b>PovilasID</b></sub></a><br /><a href="#translation-PovilasID" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://github.com/miknii"><img src="https://avatars.githubusercontent.com/u/109232569?v=4?s=100" width="100px;" alt=""/><br /><sub><b>miknii</b></sub></a><br /><a href="#translation-miknii" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/byakurau"><img src="https://avatars.githubusercontent.com/u/1811683?v=4?s=100" width="100px;" alt="byakurau"/><br /><sub><b>byakurau</b></sub></a><br /><a href="#translation-byakurau" title="Translation">🌍</a></td>
|
||||||
</tr>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/miknii"><img src="https://avatars.githubusercontent.com/u/109232569?v=4?s=100" width="100px;" alt="miknii"/><br /><sub><b>miknii</b></sub></a><br /><a href="#translation-miknii" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Eclipseop"><img src="https://avatars.githubusercontent.com/u/5846213?v=4?s=100" width="100px;" alt="Mackenzie"/><br /><sub><b>Mackenzie</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Eclipseop" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/s0up4200"><img src="https://avatars.githubusercontent.com/u/18177310?v=4?s=100" width="100px;" alt="soup"/><br /><sub><b>soup</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=s0up4200" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ceptonit"><img src="https://avatars.githubusercontent.com/u/12678743?v=4?s=100" width="100px;" alt="ceptonit"/><br /><sub><b>ceptonit</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ceptonit" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/aedelbro"><img src="https://avatars.githubusercontent.com/u/36162221?v=4?s=100" width="100px;" alt="aedelbro"/><br /><sub><b>aedelbro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=aedelbro" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://twitter.com/lunks/"><img src="https://avatars.githubusercontent.com/u/91118?v=4?s=100" width="100px;" alt="Pedro Nascimento"/><br /><sub><b>Pedro Nascimento</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=lunks" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://voke.dev"><img src="https://avatars.githubusercontent.com/u/1899334?v=4?s=100" width="100px;" alt="Owen Voke"/><br /><sub><b>Owen Voke</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=owenvoke" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Nimelrian"><img src="https://avatars.githubusercontent.com/u/8960836?v=4?s=100" width="100px;" alt="Sebastian K"/><br /><sub><b>Sebastian K</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Nimelrian" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jariz"><img src="https://avatars.githubusercontent.com/u/1415847?v=4?s=100" width="100px;" alt="jariz"/><br /><sub><b>jariz</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jariz" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://arouillard.fr"><img src="https://avatars.githubusercontent.com/u/13947260?v=4?s=100" width="100px;" alt="Alex"/><br /><sub><b>Alex</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Alexays" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zebebles"><img src="https://avatars.githubusercontent.com/u/11425451?v=4?s=100" width="100px;" alt="Zeb Muller"/><br /><sub><b>Zeb Muller</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Zebebles" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://smoores.dev"><img src="https://avatars.githubusercontent.com/u/5354254?v=4?s=100" width="100px;" alt="Shane Friedman"/><br /><sub><b>Shane Friedman</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=SMores" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://izaacj.me"><img src="https://avatars.githubusercontent.com/u/711323?v=4?s=100" width="100px;" alt="Izaac Brånn"/><br /><sub><b>Izaac Brånn</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=IzaacJ" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/SalmanTariq"><img src="https://avatars.githubusercontent.com/u/13284494?v=4?s=100" width="100px;" alt="Salman Tariq"/><br /><sub><b>Salman Tariq</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=SalmanTariq" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrew-kennedy"><img src="https://avatars.githubusercontent.com/u/2387159?v=4?s=100" width="100px;" alt="Andrew Kennedy"/><br /><sub><b>Andrew Kennedy</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=andrew-kennedy" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Fallenbagel" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://aidoge.xyz"><img src="https://avatars.githubusercontent.com/u/9427639?v=4?s=100" width="100px;" alt="Anton K. (ai Doge)"/><br /><sub><b>Anton K. (ai Doge)</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=scorp200" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://marcofaggian.com"><img src="https://avatars.githubusercontent.com/u/19221001?v=4?s=100" width="100px;" alt="Marco Faggian"/><br /><sub><b>Marco Faggian</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=marcofaggian" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://nemchik.com/"><img src="https://avatars.githubusercontent.com/u/725456?v=4?s=100" width="100px;" alt="Eric Nemchik"/><br /><sub><b>Eric Nemchik</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=nemchik" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RemiRigal"><img src="https://avatars.githubusercontent.com/u/19256051?v=4?s=100" width="100px;" alt="RemiRigal"/><br /><sub><b>RemiRigal</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=RemiRigal" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/demrich"><img src="https://avatars.githubusercontent.com/u/30092389?v=4?s=100" width="100px;" alt="David Emrich"/><br /><sub><b>David Emrich</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=demrich" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://maxtrier.dk"><img src="https://avatars.githubusercontent.com/u/5898152?v=4?s=100" width="100px;" alt="Max T. Kristiansen"/><br /><sub><b>Max T. Kristiansen</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=maxnatamo" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://damsdev.me"><img src="https://avatars.githubusercontent.com/u/60252259?v=4?s=100" width="100px;" alt="Damien Fajole"/><br /><sub><b>Damien Fajole</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=DamsDev1" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- markdownlint-restore -->
|
<!-- markdownlint-restore -->
|
||||||
|
|||||||
@@ -3,147 +3,147 @@
|
|||||||
"vapidPrivate": "tmnslaO8ZWN6bNbSEv_rolPeBTlNxOwCCAHrM9oZz3M",
|
"vapidPrivate": "tmnslaO8ZWN6bNbSEv_rolPeBTlNxOwCCAHrM9oZz3M",
|
||||||
"vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs",
|
"vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs",
|
||||||
"main": {
|
"main": {
|
||||||
"apiKey": "testkey",
|
"apiKey": "testkey",
|
||||||
"applicationTitle": "Overseerr",
|
"applicationTitle": "Overseerr",
|
||||||
"applicationUrl": "",
|
"applicationUrl": "",
|
||||||
"csrfProtection": false,
|
"csrfProtection": false,
|
||||||
"cacheImages": false,
|
"cacheImages": false,
|
||||||
"defaultPermissions": 32,
|
"defaultPermissions": 32,
|
||||||
"defaultQuotas": {
|
"defaultQuotas": {
|
||||||
"movie": {},
|
"movie": {},
|
||||||
"tv": {}
|
"tv": {}
|
||||||
},
|
},
|
||||||
"hideAvailable": false,
|
"hideAvailable": false,
|
||||||
"localLogin": true,
|
"localLogin": true,
|
||||||
"newPlexLogin": true,
|
"newPlexLogin": true,
|
||||||
"region": "",
|
"region": "",
|
||||||
"originalLanguage": "",
|
"originalLanguage": "",
|
||||||
"trustProxy": false,
|
"trustProxy": false,
|
||||||
"partialRequestsEnabled": true,
|
"partialRequestsEnabled": true,
|
||||||
"locale": "en"
|
"locale": "en"
|
||||||
},
|
},
|
||||||
"plex": {
|
"plex": {
|
||||||
"name": "Seerr",
|
"name": "Seerr",
|
||||||
"ip": "192.168.1.1",
|
"ip": "192.168.1.1",
|
||||||
"port": 32400,
|
"port": 32400,
|
||||||
"useSsl": false,
|
"useSsl": false,
|
||||||
"libraries": [
|
"libraries": [
|
||||||
{
|
{
|
||||||
"id": "1",
|
"id": "1",
|
||||||
"name": "Movies",
|
"name": "Movies",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"type": "movie"
|
"type": "movie"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"machineId": "test"
|
"machineId": "test"
|
||||||
},
|
},
|
||||||
"tautulli": {},
|
"tautulli": {},
|
||||||
"radarr": [],
|
"radarr": [],
|
||||||
"sonarr": [],
|
"sonarr": [],
|
||||||
"public": {
|
"public": {
|
||||||
"initialized": true
|
"initialized": true
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"agents": {
|
"agents": {
|
||||||
"email": {
|
"email": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"options": {
|
"options": {
|
||||||
"emailFrom": "",
|
"emailFrom": "",
|
||||||
"smtpHost": "",
|
"smtpHost": "",
|
||||||
"smtpPort": 587,
|
"smtpPort": 587,
|
||||||
"secure": false,
|
"secure": false,
|
||||||
"ignoreTls": false,
|
"ignoreTls": false,
|
||||||
"requireTls": false,
|
"requireTls": false,
|
||||||
"allowSelfSigned": false,
|
"allowSelfSigned": false,
|
||||||
"senderName": "Overseerr"
|
"senderName": "Overseerr"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"webhookUrl": "",
|
"webhookUrl": "",
|
||||||
"enableMentions": true
|
"enableMentions": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lunasea": {
|
"lunasea": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"webhookUrl": ""
|
"webhookUrl": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"slack": {
|
"slack": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"webhookUrl": ""
|
"webhookUrl": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"botAPI": "",
|
"botAPI": "",
|
||||||
"chatId": "",
|
"chatId": "",
|
||||||
"sendSilently": false
|
"sendSilently": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pushbullet": {
|
"pushbullet": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"accessToken": ""
|
"accessToken": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pushover": {
|
"pushover": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"accessToken": "",
|
"accessToken": "",
|
||||||
"userToken": ""
|
"userToken": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"webhook": {
|
"webhook": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"webhookUrl": "",
|
"webhookUrl": "",
|
||||||
"jsonPayload": "IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i"
|
"jsonPayload": "IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"webpush": {
|
"webpush": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"options": {}
|
"options": {}
|
||||||
},
|
},
|
||||||
"gotify": {
|
"gotify": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"url": "",
|
"url": "",
|
||||||
"token": ""
|
"token": ""
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"plex-recently-added-scan": {
|
"plex-recently-added-scan": {
|
||||||
"schedule": "0 */5 * * * *"
|
"schedule": "0 */5 * * * *"
|
||||||
},
|
},
|
||||||
"plex-full-scan": {
|
"plex-full-scan": {
|
||||||
"schedule": "0 0 3 * * *"
|
"schedule": "0 0 3 * * *"
|
||||||
},
|
},
|
||||||
"radarr-scan": {
|
"radarr-scan": {
|
||||||
"schedule": "0 0 4 * * *"
|
"schedule": "0 0 4 * * *"
|
||||||
},
|
},
|
||||||
"sonarr-scan": {
|
"sonarr-scan": {
|
||||||
"schedule": "0 30 4 * * *"
|
"schedule": "0 30 4 * * *"
|
||||||
},
|
},
|
||||||
"download-sync": {
|
"download-sync": {
|
||||||
"schedule": "0 * * * * *"
|
"schedule": "0 * * * * *"
|
||||||
},
|
},
|
||||||
"download-sync-reset": {
|
"download-sync-reset": {
|
||||||
"schedule": "0 0 1 * * *"
|
"schedule": "0 0 1 * * *"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const clickFirstTitleCardInSlider = (sliderTitle: string): void => {
|
|||||||
|
|
||||||
describe('Discover', () => {
|
describe('Discover', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
|
cy.loginAsAdmin();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads a trending item', () => {
|
it('loads a trending item', () => {
|
||||||
@@ -36,7 +36,9 @@ describe('Discover', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('loads upcoming movies', () => {
|
it('loads upcoming movies', () => {
|
||||||
cy.intercept('/api/v1/discover/movies/upcoming*').as('getUpcomingMovies');
|
cy.intercept('/api/v1/discover/movies?page=1&primaryReleaseDateGte*').as(
|
||||||
|
'getUpcomingMovies'
|
||||||
|
);
|
||||||
cy.visit('/');
|
cy.visit('/');
|
||||||
cy.wait('@getUpcomingMovies');
|
cy.wait('@getUpcomingMovies');
|
||||||
clickFirstTitleCardInSlider('Upcoming Movies');
|
clickFirstTitleCardInSlider('Upcoming Movies');
|
||||||
@@ -50,7 +52,9 @@ describe('Discover', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('loads upcoming series', () => {
|
it('loads upcoming series', () => {
|
||||||
cy.intercept('/api/v1/discover/tv/upcoming*').as('getUpcomingSeries');
|
cy.intercept('/api/v1/discover/tv?page=1&firstAirDateGte=*').as(
|
||||||
|
'getUpcomingSeries'
|
||||||
|
);
|
||||||
cy.visit('/');
|
cy.visit('/');
|
||||||
cy.wait('@getUpcomingSeries');
|
cy.wait('@getUpcomingSeries');
|
||||||
clickFirstTitleCardInSlider('Upcoming Series');
|
clickFirstTitleCardInSlider('Upcoming Series');
|
||||||
@@ -171,4 +175,40 @@ describe('Discover', () => {
|
|||||||
.find('[data-testid=request-card-title]')
|
.find('[data-testid=request-card-title]')
|
||||||
.contains('Movie Not Found');
|
.contains('Movie Not Found');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('loads plex watchlist', () => {
|
||||||
|
cy.intercept('/api/v1/discover/watchlist', {
|
||||||
|
fixture: 'watchlist.json',
|
||||||
|
}).as('getWatchlist');
|
||||||
|
// Wait for one of the watchlist movies to resolve
|
||||||
|
cy.intercept('/api/v1/movie/361743').as('getTmdbMovie');
|
||||||
|
|
||||||
|
cy.visit('/');
|
||||||
|
|
||||||
|
cy.wait('@getWatchlist');
|
||||||
|
|
||||||
|
const sliderHeader = cy.contains('.slider-header', 'Your Plex Watchlist');
|
||||||
|
|
||||||
|
sliderHeader.scrollIntoView();
|
||||||
|
|
||||||
|
cy.wait('@getTmdbMovie');
|
||||||
|
// Wait a little longer to make sure the movie component reloaded
|
||||||
|
cy.wait(500);
|
||||||
|
|
||||||
|
sliderHeader
|
||||||
|
.next('[data-testid=media-slider]')
|
||||||
|
.find('[data-testid=title-card]')
|
||||||
|
.first()
|
||||||
|
.trigger('mouseover')
|
||||||
|
.find('[data-testid=title-card-title]')
|
||||||
|
.invoke('text')
|
||||||
|
.then((text) => {
|
||||||
|
cy.contains('.slider-header', 'Plex Watchlist')
|
||||||
|
.next('[data-testid=media-slider]')
|
||||||
|
.find('[data-testid=title-card]')
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
cy.get('[data-testid=media-title]').should('contain', text);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
describe('Login Page', () => {
|
describe('Login Page', () => {
|
||||||
it('succesfully logs in as an admin', () => {
|
it('succesfully logs in as an admin', () => {
|
||||||
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
|
cy.loginAsAdmin();
|
||||||
cy.visit('/');
|
cy.visit('/');
|
||||||
cy.contains('Trending');
|
cy.contains('Trending');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('succesfully logs in as a local user', () => {
|
it('succesfully logs in as a local user', () => {
|
||||||
cy.login(Cypress.env('USER_EMAIL'), Cypress.env('USER_PASSWORD'));
|
cy.loginAsUser();
|
||||||
cy.visit('/');
|
cy.visit('/');
|
||||||
cy.contains('Trending');
|
cy.contains('Trending');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
describe('Movie Details', () => {
|
describe('Movie Details', () => {
|
||||||
it('loads a movie page', () => {
|
it('loads a movie page', () => {
|
||||||
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
|
cy.loginAsAdmin();
|
||||||
// Try to load minions: rise of gru
|
// Try to load minions: rise of gru
|
||||||
cy.visit('/movie/438148');
|
cy.visit('/movie/438148');
|
||||||
|
|
||||||
|
|||||||
25
cypress/e2e/pull-to-refresh.cy.ts
Normal file
25
cypress/e2e/pull-to-refresh.cy.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
describe('Pull To Refresh', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
|
||||||
|
cy.viewport(390, 844);
|
||||||
|
cy.visitMobile('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reloads the current page', () => {
|
||||||
|
cy.wait(500);
|
||||||
|
|
||||||
|
cy.intercept({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/*',
|
||||||
|
}).as('apiCall');
|
||||||
|
|
||||||
|
cy.get('.searchbar').swipe('bottom', [190, 500]);
|
||||||
|
|
||||||
|
cy.wait('@apiCall').then((interception) => {
|
||||||
|
assert.isNotNull(
|
||||||
|
interception.response.body,
|
||||||
|
'API was called and received data'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
163
cypress/e2e/settings/discover-customization.cy.ts
Normal file
163
cypress/e2e/settings/discover-customization.cy.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
describe('Discover Customization', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.loginAsAdmin();
|
||||||
|
cy.intercept('/api/v1/settings/discover').as('getDiscoverSliders');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('show the discover customization settings', () => {
|
||||||
|
cy.visit('/');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-start-editing]').click();
|
||||||
|
|
||||||
|
cy.get('[data-testid=create-slider-header')
|
||||||
|
.should('contain', 'Create New Slider')
|
||||||
|
.scrollIntoView();
|
||||||
|
|
||||||
|
// There should be some built in options
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]').should(
|
||||||
|
'contain',
|
||||||
|
'Recently Added'
|
||||||
|
);
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]').should(
|
||||||
|
'contain',
|
||||||
|
'Recent Requests'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can drag to re-order elements and save to persist the changes', () => {
|
||||||
|
let dataTransfer = new DataTransfer();
|
||||||
|
cy.visit('/');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-start-editing]').click();
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.first()
|
||||||
|
.trigger('dragstart', { dataTransfer });
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.eq(1)
|
||||||
|
.trigger('drop', { dataTransfer });
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.eq(1)
|
||||||
|
.trigger('dragend', { dataTransfer });
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.eq(1)
|
||||||
|
.should('contain', 'Recently Added');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-customize-submit').click();
|
||||||
|
cy.wait('@getDiscoverSliders');
|
||||||
|
|
||||||
|
cy.reload();
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-start-editing]').click();
|
||||||
|
|
||||||
|
dataTransfer = new DataTransfer();
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.eq(1)
|
||||||
|
.should('contain', 'Recently Added');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.first()
|
||||||
|
.trigger('dragstart', { dataTransfer });
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.eq(1)
|
||||||
|
.trigger('drop', { dataTransfer });
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.eq(1)
|
||||||
|
.trigger('dragend', { dataTransfer });
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.eq(1)
|
||||||
|
.should('contain', 'Recent Requests');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-customize-submit').click();
|
||||||
|
cy.wait('@getDiscoverSliders');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can create a new discover option and remove it', () => {
|
||||||
|
cy.visit('/');
|
||||||
|
cy.intercept('/api/v1/settings/discover/*').as('discoverSlider');
|
||||||
|
cy.intercept('/api/v1/search/keyword*').as('searchKeyword');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-start-editing]').click();
|
||||||
|
|
||||||
|
const sliderTitle = 'Custom Keyword Slider';
|
||||||
|
|
||||||
|
cy.get('#sliderType').select('TMDB Movie Keyword');
|
||||||
|
|
||||||
|
cy.get('#title').type(sliderTitle);
|
||||||
|
// First confirm that an invalid keyword doesn't allow us to submit anything
|
||||||
|
cy.get('#data').type('invalidkeyword{enter}', { delay: 100 });
|
||||||
|
cy.wait('@searchKeyword');
|
||||||
|
|
||||||
|
cy.get('[data-testid=create-discover-option-form]')
|
||||||
|
.find('button')
|
||||||
|
.should('be.disabled');
|
||||||
|
|
||||||
|
cy.get('#data').clear();
|
||||||
|
cy.get('#data').type('christmas{enter}', { delay: 100 });
|
||||||
|
|
||||||
|
// Confirming we have some results
|
||||||
|
cy.contains('.slider-header', sliderTitle)
|
||||||
|
.next('[data-testid=media-slider]')
|
||||||
|
.find('[data-testid=title-card]');
|
||||||
|
|
||||||
|
cy.get('[data-testid=create-discover-option-form]').submit();
|
||||||
|
|
||||||
|
cy.wait('@discoverSlider');
|
||||||
|
cy.wait('@getDiscoverSliders');
|
||||||
|
cy.wait(1000);
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.first()
|
||||||
|
.should('contain', sliderTitle);
|
||||||
|
|
||||||
|
// Make sure its still there even if we reload
|
||||||
|
cy.reload();
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-start-editing]').click();
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.first()
|
||||||
|
.should('contain', sliderTitle);
|
||||||
|
|
||||||
|
// Verify it's not rendering on our discover page (its still disabled!)
|
||||||
|
cy.visit('/');
|
||||||
|
|
||||||
|
cy.get('.slider-header').should('not.contain', sliderTitle);
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-start-editing]').click();
|
||||||
|
|
||||||
|
// Enable it, and check again
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.first()
|
||||||
|
.find('[role="checkbox"]')
|
||||||
|
.click();
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-customize-submit').click();
|
||||||
|
cy.wait('@getDiscoverSliders');
|
||||||
|
|
||||||
|
cy.visit('/');
|
||||||
|
|
||||||
|
cy.contains('.slider-header', sliderTitle)
|
||||||
|
.next('[data-testid=media-slider]')
|
||||||
|
.find('[data-testid=title-card]');
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-start-editing]').click();
|
||||||
|
|
||||||
|
// let's delete it and confirm its deleted.
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.first()
|
||||||
|
.find('[data-testid=discover-slider-remove-button]')
|
||||||
|
.click();
|
||||||
|
|
||||||
|
cy.wait('@discoverSlider');
|
||||||
|
cy.wait('@getDiscoverSliders');
|
||||||
|
cy.wait(1000);
|
||||||
|
|
||||||
|
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||||
|
.first()
|
||||||
|
.should('not.contain', sliderTitle);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
describe('General Settings', () => {
|
describe('General Settings', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
|
cy.loginAsAdmin();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens the settings page from the home page', () => {
|
it('opens the settings page from the home page', () => {
|
||||||
@@ -16,7 +16,7 @@ describe('General Settings', () => {
|
|||||||
cy.visit('/settings');
|
cy.visit('/settings');
|
||||||
|
|
||||||
cy.get('#trustProxy').click();
|
cy.get('#trustProxy').click();
|
||||||
cy.get('form').submit();
|
cy.get('[data-testid=settings-main-form]').submit();
|
||||||
cy.get('[data-testid=modal-title]').should(
|
cy.get('[data-testid=modal-title]').should(
|
||||||
'contain',
|
'contain',
|
||||||
'Server Restart Required'
|
'Server Restart Required'
|
||||||
@@ -26,7 +26,7 @@ describe('General Settings', () => {
|
|||||||
cy.get('[data-testid=modal-title]').should('not.exist');
|
cy.get('[data-testid=modal-title]').should('not.exist');
|
||||||
|
|
||||||
cy.get('[type=checkbox]#trustProxy').click();
|
cy.get('[type=checkbox]#trustProxy').click();
|
||||||
cy.get('form').submit();
|
cy.get('[data-testid=settings-main-form]').submit();
|
||||||
cy.get('[data-testid=modal-title]').should('not.exist');
|
cy.get('[data-testid=modal-title]').should('not.exist');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
describe('TV Details', () => {
|
describe('TV Details', () => {
|
||||||
it('loads a movie page', () => {
|
it('loads a tv details page', () => {
|
||||||
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
|
cy.loginAsAdmin();
|
||||||
// Try to load stranger things
|
// Try to load stranger things
|
||||||
cy.visit('/tv/66732');
|
cy.visit('/tv/66732');
|
||||||
|
|
||||||
@@ -9,4 +9,20 @@ describe('TV Details', () => {
|
|||||||
'Stranger Things (2016)'
|
'Stranger Things (2016)'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows seasons and expands episodes', () => {
|
||||||
|
cy.loginAsAdmin();
|
||||||
|
|
||||||
|
// Try to load stranger things
|
||||||
|
cy.visit('/tv/66732');
|
||||||
|
|
||||||
|
// intercept request for season info
|
||||||
|
cy.intercept('/api/v1/tv/66732/season/4').as('season4');
|
||||||
|
|
||||||
|
cy.contains('Season 4').should('be.visible').scrollIntoView().click();
|
||||||
|
|
||||||
|
cy.wait('@season4');
|
||||||
|
|
||||||
|
cy.contains('Chapter Nine').should('be.visible');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
74
cypress/e2e/user/auto-request-settings.cy.ts
Normal file
74
cypress/e2e/user/auto-request-settings.cy.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
const visitUserEditPage = (email: string): void => {
|
||||||
|
cy.visit('/users');
|
||||||
|
|
||||||
|
cy.contains('[data-testid=user-list-row]', email).contains('Edit').click();
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Auto Request Settings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.loginAsAdmin();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not see watchlist sync settings on an account without permissions', () => {
|
||||||
|
visitUserEditPage(Cypress.env('USER_EMAIL'));
|
||||||
|
|
||||||
|
cy.contains('Auto-Request Movies').should('not.exist');
|
||||||
|
cy.contains('Auto-Request Series').should('not.exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should see watchlist sync settings on an admin account', () => {
|
||||||
|
visitUserEditPage(Cypress.env('ADMIN_EMAIL'));
|
||||||
|
|
||||||
|
cy.contains('Auto-Request Movies').should('exist');
|
||||||
|
cy.contains('Auto-Request Series').should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should see auto-request settings after being given permission', () => {
|
||||||
|
visitUserEditPage(Cypress.env('USER_EMAIL'));
|
||||||
|
|
||||||
|
cy.get('[data-testid=settings-nav-desktop').contains('Permissions').click();
|
||||||
|
|
||||||
|
cy.get('#autorequest').should('not.be.checked').click();
|
||||||
|
|
||||||
|
cy.intercept('/api/v1/user/*/settings/permissions').as('userPermissions');
|
||||||
|
|
||||||
|
cy.contains('Save Changes').click();
|
||||||
|
|
||||||
|
cy.wait('@userPermissions');
|
||||||
|
|
||||||
|
cy.reload();
|
||||||
|
|
||||||
|
cy.get('#autorequest').should('be.checked');
|
||||||
|
cy.get('#autorequestmovies').should('be.checked');
|
||||||
|
cy.get('#autorequesttv').should('be.checked');
|
||||||
|
|
||||||
|
cy.get('[data-testid=settings-nav-desktop').contains('General').click();
|
||||||
|
|
||||||
|
cy.contains('Auto-Request Movies').should('exist');
|
||||||
|
cy.contains('Auto-Request Series').should('exist');
|
||||||
|
|
||||||
|
cy.get('#watchlistSyncMovies').should('not.be.checked').click();
|
||||||
|
cy.get('#watchlistSyncTv').should('not.be.checked').click();
|
||||||
|
|
||||||
|
cy.intercept('/api/v1/user/*/settings/main').as('userMain');
|
||||||
|
|
||||||
|
cy.contains('Save Changes').click();
|
||||||
|
|
||||||
|
cy.wait('@userMain');
|
||||||
|
|
||||||
|
cy.reload();
|
||||||
|
|
||||||
|
cy.get('#watchlistSyncMovies').should('be.checked').click();
|
||||||
|
cy.get('#watchlistSyncTv').should('be.checked').click();
|
||||||
|
|
||||||
|
cy.contains('Save Changes').click();
|
||||||
|
|
||||||
|
cy.wait('@userMain');
|
||||||
|
|
||||||
|
cy.get('[data-testid=settings-nav-desktop').contains('Permissions').click();
|
||||||
|
|
||||||
|
cy.get('#autorequest').should('be.checked').click();
|
||||||
|
|
||||||
|
cy.contains('Save Changes').click();
|
||||||
|
});
|
||||||
|
});
|
||||||
50
cypress/e2e/user/profile.cy.ts
Normal file
50
cypress/e2e/user/profile.cy.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
describe('User Profile', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.loginAsAdmin();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens user profile page from the home page', () => {
|
||||||
|
cy.visit('/');
|
||||||
|
|
||||||
|
cy.get('[data-testid=user-menu]').click();
|
||||||
|
cy.get('[data-testid=user-menu-profile]').click();
|
||||||
|
|
||||||
|
cy.get('h1').should('contain', Cypress.env('ADMIN_EMAIL'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads plex watchlist', () => {
|
||||||
|
cy.intercept('/api/v1/user/[0-9]*/watchlist', {
|
||||||
|
fixture: 'watchlist.json',
|
||||||
|
}).as('getWatchlist');
|
||||||
|
// Wait for one of the watchlist movies to resolve
|
||||||
|
cy.intercept('/api/v1/movie/361743').as('getTmdbMovie');
|
||||||
|
|
||||||
|
cy.visit('/profile');
|
||||||
|
|
||||||
|
cy.wait('@getWatchlist');
|
||||||
|
|
||||||
|
const sliderHeader = cy.contains('.slider-header', 'Plex Watchlist');
|
||||||
|
|
||||||
|
sliderHeader.scrollIntoView();
|
||||||
|
|
||||||
|
cy.wait('@getTmdbMovie');
|
||||||
|
// Wait a little longer to make sure the movie component reloaded
|
||||||
|
cy.wait(500);
|
||||||
|
|
||||||
|
sliderHeader
|
||||||
|
.next('[data-testid=media-slider]')
|
||||||
|
.find('[data-testid=title-card]')
|
||||||
|
.first()
|
||||||
|
.trigger('mouseover')
|
||||||
|
.find('[data-testid=title-card-title]')
|
||||||
|
.invoke('text')
|
||||||
|
.then((text) => {
|
||||||
|
cy.contains('.slider-header', 'Plex Watchlist')
|
||||||
|
.next('[data-testid=media-slider]')
|
||||||
|
.find('[data-testid=title-card]')
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
cy.get('[data-testid=media-title]').should('contain', text);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ const testUser = {
|
|||||||
|
|
||||||
describe('User List', () => {
|
describe('User List', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
|
cy.loginAsAdmin();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens the user list from the home page', () => {
|
it('opens the user list from the home page', () => {
|
||||||
@@ -54,10 +54,7 @@ describe('User List', () => {
|
|||||||
.contains('Delete')
|
.contains('Delete')
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
cy.get('[data-testid=modal-title]').should(
|
cy.get('[data-testid=modal-title]').should('contain', `Delete User`);
|
||||||
'contain',
|
|
||||||
`Delete ${testUser.displayName}`
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user');
|
cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user');
|
||||||
|
|
||||||
|
|||||||
25
cypress/fixtures/watchlist.json
Normal file
25
cypress/fixtures/watchlist.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"page": 1,
|
||||||
|
"totalPages": 1,
|
||||||
|
"totalResults": 3,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"ratingKey": "5d776be17a53e9001e732ab9",
|
||||||
|
"title": "Top Gun: Maverick",
|
||||||
|
"mediaType": "movie",
|
||||||
|
"tmdbId": 361743
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ratingKey": "5e16338fbc1372003ea68ab3",
|
||||||
|
"title": "Nope",
|
||||||
|
"mediaType": "movie",
|
||||||
|
"tmdbId": 762504
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ratingKey": "5f409b8452f200004161e126",
|
||||||
|
"title": "Hocus Pocus 2",
|
||||||
|
"mediaType": "movie",
|
||||||
|
"tmdbId": 642885
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
/// <reference types="cypress" />
|
/// <reference types="cypress" />
|
||||||
|
import 'cy-mobile-commands';
|
||||||
|
|
||||||
Cypress.Commands.add('login', (email, password) => {
|
Cypress.Commands.add('login', (email, password) => {
|
||||||
cy.session(
|
cy.session(
|
||||||
@@ -24,3 +25,11 @@ Cypress.Commands.add('login', (email, password) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add('loginAsAdmin', () => {
|
||||||
|
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
|
||||||
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add('loginAsUser', () => {
|
||||||
|
cy.login(Cypress.env('USER_EMAIL'), Cypress.env('USER_PASSWORD'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ declare global {
|
|||||||
namespace Cypress {
|
namespace Cypress {
|
||||||
interface Chainable {
|
interface Chainable {
|
||||||
login(email?: string, password?: string): Chainable<Element>;
|
login(email?: string, password?: string): Chainable<Element>;
|
||||||
|
loginAsAdmin(): Chainable<Element>;
|
||||||
|
loginAsUser(): Chainable<Element>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ To use Fail2ban with Overseerr, create a new file named `overseerr.local` in you
|
|||||||
failregex = .*\[warn\]\[API\]\: Failed sign-in attempt.*"ip":"<HOST>"
|
failregex = .*\[warn\]\[API\]\: Failed sign-in attempt.*"ip":"<HOST>"
|
||||||
```
|
```
|
||||||
|
|
||||||
You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documetation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail.
|
You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documentation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail.
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ location ^~ /overseerr {
|
|||||||
sub_filter 'href="/"' 'href="/$app"';
|
sub_filter 'href="/"' 'href="/$app"';
|
||||||
sub_filter 'href="/login"' 'href="/$app/login"';
|
sub_filter 'href="/login"' 'href="/$app/login"';
|
||||||
sub_filter 'href:"/"' 'href:"/$app"';
|
sub_filter 'href:"/"' 'href:"/$app"';
|
||||||
|
sub_filter '\/_next' '\/$app\/_next';
|
||||||
sub_filter '/_next' '/$app/_next';
|
sub_filter '/_next' '/$app/_next';
|
||||||
sub_filter '/api/v1' '/$app/api/v1';
|
sub_filter '/api/v1' '/$app/api/v1';
|
||||||
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
|
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ docker run -d \
|
|||||||
--name overseerr \
|
--name overseerr \
|
||||||
-e LOG_LEVEL=debug \
|
-e LOG_LEVEL=debug \
|
||||||
-e TZ=Asia/Tokyo \
|
-e TZ=Asia/Tokyo \
|
||||||
|
-e PORT=5055 `#optional` \
|
||||||
-p 5055:5055 \
|
-p 5055:5055 \
|
||||||
-v /path/to/appdata/config:/app/config \
|
-v /path/to/appdata/config:/app/config \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
@@ -81,6 +82,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- LOG_LEVEL=debug
|
- LOG_LEVEL=debug
|
||||||
- TZ=Asia/Tokyo
|
- TZ=Asia/Tokyo
|
||||||
|
- PORT=5055 #optional
|
||||||
ports:
|
ports:
|
||||||
- 5055:5055
|
- 5055:5055
|
||||||
volumes:
|
volumes:
|
||||||
@@ -88,7 +90,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, start all services defined in the your Compose file:
|
Then, start all services defined in the Compose file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
@@ -146,8 +148,6 @@ Then, create and start the Overseerr container:
|
|||||||
docker run -d --name overseerr -e LOG_LEVEL=debug -e TZ=Asia/Tokyo -p 5055:5055 -v "overseerr-data:/app/config" --restart unless-stopped sctx/overseerr:latest
|
docker run -d --name overseerr -e LOG_LEVEL=debug -e TZ=Asia/Tokyo -p 5055:5055 -v "overseerr-data:/app/config" --restart unless-stopped sctx/overseerr:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
If using a named volume like above, you can safely ignore the warning about the `/app/config` folder being incorrectly mounted on the setup page.
|
|
||||||
|
|
||||||
To access the files inside the volume created above, navigate to `\\wsl$\docker-desktop-data\version-pack-data\community\docker\volumes\overseerr-data\_data` using File Explorer.
|
To access the files inside the volume created above, navigate to `\\wsl$\docker-desktop-data\version-pack-data\community\docker\volumes\overseerr-data\_data` using File Explorer.
|
||||||
|
|
||||||
{% hint style="info" %}
|
{% hint style="info" %}
|
||||||
@@ -155,7 +155,7 @@ Docker on Windows works differently than it does on Linux; it runs Docker inside
|
|||||||
|
|
||||||
**If you must run Docker on Windows, you should put the `/app/config` directory mount inside the VM and not on the Windows host.** (This also applies to other containers with SQLite databases.)
|
**If you must run Docker on Windows, you should put the `/app/config` directory mount inside the VM and not on the Windows host.** (This also applies to other containers with SQLite databases.)
|
||||||
|
|
||||||
Named volumes, like in the example commands above, are automatically mounted inside the VM.
|
Named volumes, like in the example commands above, are automatically mounted inside the VM. Therefore the warning on the setup about the `/app/config` folder being incorrectly mounted page should be ignored.
|
||||||
{% endhint %}
|
{% endhint %}
|
||||||
|
|
||||||
## Linux
|
## Linux
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ If you enable this setting and find yourself unable to access Overseerr, you can
|
|||||||
|
|
||||||
This setting is **disabled** by default.
|
This setting is **disabled** by default.
|
||||||
|
|
||||||
|
### Enable Image Caching
|
||||||
|
|
||||||
|
When enabled, Overseerr will proxy and cache images from pre-configured sources (such as TMDB). This can use a significant amount of disk space.
|
||||||
|
|
||||||
|
Images are saved in the `config/cache/images` and stale images are cleared out every 24 hours.
|
||||||
|
|
||||||
|
You should enable this if you are having issues with loading images directly from TMDB in your browser.
|
||||||
|
|
||||||
### Display Language
|
### Display Language
|
||||||
|
|
||||||
Set the default display language for Overseerr. Users can override this setting in their user settings.
|
Set the default display language for Overseerr. Users can override this setting in their user settings.
|
||||||
|
|||||||
21
merged-prettier-plugin.js
Normal file
21
merged-prettier-plugin.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
const tailwind = require('prettier-plugin-tailwindcss');
|
||||||
|
const organizeImports = require('prettier-plugin-organize-imports');
|
||||||
|
|
||||||
|
const combinedFormatter = {
|
||||||
|
...tailwind,
|
||||||
|
parsers: {
|
||||||
|
...tailwind.parsers,
|
||||||
|
...Object.keys(organizeImports.parsers).reduce((acc, key) => {
|
||||||
|
acc[key] = {
|
||||||
|
...tailwind.parsers[key],
|
||||||
|
preprocess(code, options) {
|
||||||
|
return organizeImports.parsers[key].preprocess(code, options);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = combinedFormatter;
|
||||||
@@ -17,4 +17,8 @@ module.exports = {
|
|||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
|
experimental: {
|
||||||
|
scrollRestoration: true,
|
||||||
|
largePageDataBytes: 256000,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ tags:
|
|||||||
description: Endpoints related to retrieving movies and their details.
|
description: Endpoints related to retrieving movies and their details.
|
||||||
- name: tv
|
- name: tv
|
||||||
description: Endpoints related to retrieving TV series and their details.
|
description: Endpoints related to retrieving TV series and their details.
|
||||||
|
- name: other
|
||||||
|
description: Endpoints related to other TMDB data
|
||||||
- name: person
|
- name: person
|
||||||
description: Endpoints related to retrieving person details.
|
description: Endpoints related to retrieving person details.
|
||||||
- name: media
|
- name: media
|
||||||
@@ -600,6 +602,17 @@ components:
|
|||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
example: Adventure
|
example: Adventure
|
||||||
|
Company:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
logo_path:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
ProductionCompany:
|
ProductionCompany:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -1039,6 +1052,8 @@ components:
|
|||||||
nullable: true
|
nullable: true
|
||||||
status:
|
status:
|
||||||
type: number
|
type: number
|
||||||
|
example: 0
|
||||||
|
description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`
|
||||||
requests:
|
requests:
|
||||||
type: array
|
type: array
|
||||||
readOnly: true
|
readOnly: true
|
||||||
@@ -1258,6 +1273,8 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
userToken:
|
userToken:
|
||||||
type: string
|
type: string
|
||||||
|
sound:
|
||||||
|
type: string
|
||||||
GotifySettings:
|
GotifySettings:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -1693,6 +1710,9 @@ components:
|
|||||||
pushoverUserKey:
|
pushoverUserKey:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
|
pushoverSound:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
telegramEnabled:
|
telegramEnabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
telegramBotUsername:
|
telegramBotUsername:
|
||||||
@@ -1780,6 +1800,40 @@ components:
|
|||||||
message:
|
message:
|
||||||
type: string
|
type: string
|
||||||
example: A comment
|
example: A comment
|
||||||
|
DiscoverSlider:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
type:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
isBuiltIn:
|
||||||
|
type: boolean
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
data:
|
||||||
|
type: string
|
||||||
|
example: '1234'
|
||||||
|
nullable: true
|
||||||
|
required:
|
||||||
|
- type
|
||||||
|
- enabled
|
||||||
|
- title
|
||||||
|
- data
|
||||||
|
WatchProviderRegion:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
iso_3166_1:
|
||||||
|
type: string
|
||||||
|
english_name:
|
||||||
|
type: string
|
||||||
|
native_name:
|
||||||
|
type: string
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
cookieAuth:
|
cookieAuth:
|
||||||
type: apiKey
|
type: apiKey
|
||||||
@@ -2475,29 +2529,44 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: array
|
type: object
|
||||||
items:
|
properties:
|
||||||
type: object
|
imageCache:
|
||||||
properties:
|
type: object
|
||||||
id:
|
properties:
|
||||||
type: string
|
tmdb:
|
||||||
example: cache-id
|
type: object
|
||||||
name:
|
properties:
|
||||||
type: string
|
size:
|
||||||
example: cache name
|
type: number
|
||||||
stats:
|
example: 123456
|
||||||
|
imageCount:
|
||||||
|
type: number
|
||||||
|
example: 123
|
||||||
|
apiCaches:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
hits:
|
id:
|
||||||
type: number
|
type: string
|
||||||
misses:
|
example: cache-id
|
||||||
type: number
|
name:
|
||||||
keys:
|
type: string
|
||||||
type: number
|
example: cache name
|
||||||
ksize:
|
stats:
|
||||||
type: number
|
type: object
|
||||||
vsize:
|
properties:
|
||||||
type: number
|
hits:
|
||||||
|
type: number
|
||||||
|
misses:
|
||||||
|
type: number
|
||||||
|
keys:
|
||||||
|
type: number
|
||||||
|
ksize:
|
||||||
|
type: number
|
||||||
|
vsize:
|
||||||
|
type: number
|
||||||
/settings/cache/{cacheId}/flush:
|
/settings/cache/{cacheId}/flush:
|
||||||
post:
|
post:
|
||||||
summary: Flush a specific cache
|
summary: Flush a specific cache
|
||||||
@@ -2539,6 +2608,12 @@ paths:
|
|||||||
nullable: true
|
nullable: true
|
||||||
enum: [debug, info, warn, error]
|
enum: [debug, info, warn, error]
|
||||||
default: debug
|
default: debug
|
||||||
|
- in: query
|
||||||
|
name: search
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
example: plex
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Server log returned
|
description: Server log returned
|
||||||
@@ -2791,6 +2866,33 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'204':
|
'204':
|
||||||
description: Test notification attempted
|
description: Test notification attempted
|
||||||
|
/settings/notifications/pushover/sounds:
|
||||||
|
get:
|
||||||
|
summary: Get Pushover sounds
|
||||||
|
description: Returns valid Pushover sound options in a JSON array.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: token
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
nullable: false
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Returned Pushover settings
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
/settings/notifications/gotify:
|
/settings/notifications/gotify:
|
||||||
get:
|
get:
|
||||||
summary: Get Gotify notification settings
|
summary: Get Gotify notification settings
|
||||||
@@ -3021,6 +3123,139 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'204':
|
'204':
|
||||||
description: Test notification attempted
|
description: Test notification attempted
|
||||||
|
/settings/discover:
|
||||||
|
get:
|
||||||
|
summary: Get all discover sliders
|
||||||
|
description: Returns all discovery sliders. Built-in and custom made.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Returned all discovery sliders
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/DiscoverSlider'
|
||||||
|
post:
|
||||||
|
summary: Batch update all sliders.
|
||||||
|
description: |
|
||||||
|
Batch update all sliders at once. Should also be used for creation. Will only update sliders provided
|
||||||
|
and will not delete any sliders not present in the request. If a slider is missing a required field,
|
||||||
|
it will be ignored. Requires the `ADMIN` permission.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/DiscoverSlider'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Returned all newly updated discovery sliders
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/DiscoverSlider'
|
||||||
|
/settings/discover/{sliderId}:
|
||||||
|
put:
|
||||||
|
summary: Update a single slider
|
||||||
|
description: |
|
||||||
|
Updates a single slider and return the newly updated slider. Requires the `ADMIN` permission.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: sliderId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
example: 'Slider Title'
|
||||||
|
type:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
data:
|
||||||
|
type: string
|
||||||
|
example: '1'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Returns newly added discovery slider
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DiscoverSlider'
|
||||||
|
delete:
|
||||||
|
summary: Delete slider by ID
|
||||||
|
description: Deletes the slider with the provided sliderId. Requires the `ADMIN` permission.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: sliderId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Slider successfully deleted
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DiscoverSlider'
|
||||||
|
/settings/discover/add:
|
||||||
|
post:
|
||||||
|
summary: Add a new slider
|
||||||
|
description: |
|
||||||
|
Add a single slider and return the newly created slider. Requires the `ADMIN` permission.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
example: 'New Slider'
|
||||||
|
type:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
data:
|
||||||
|
type: string
|
||||||
|
example: '1'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Returns newly added discovery slider
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DiscoverSlider'
|
||||||
|
/settings/discover/reset:
|
||||||
|
get:
|
||||||
|
summary: Reset all discover sliders
|
||||||
|
description: Resets all discovery sliders to the default values. Requires the `ADMIN` permission.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: All sliders reset to defaults
|
||||||
/settings/about:
|
/settings/about:
|
||||||
get:
|
get:
|
||||||
summary: Get server stats
|
summary: Get server stats
|
||||||
@@ -3238,7 +3473,7 @@ paths:
|
|||||||
results:
|
results:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/User'
|
$ref: '#/components/schemas/User'
|
||||||
post:
|
post:
|
||||||
summary: Create new user
|
summary: Create new user
|
||||||
description: |
|
description: |
|
||||||
@@ -3418,7 +3653,7 @@ paths:
|
|||||||
$ref: '#/components/schemas/User'
|
$ref: '#/components/schemas/User'
|
||||||
/user/{userId}/requests:
|
/user/{userId}/requests:
|
||||||
get:
|
get:
|
||||||
summary: Get user by ID
|
summary: Get requests for a specific user
|
||||||
description: |
|
description: |
|
||||||
Retrieves a user's requests in a JSON object.
|
Retrieves a user's requests in a JSON object.
|
||||||
tags:
|
tags:
|
||||||
@@ -3512,6 +3747,53 @@ paths:
|
|||||||
restricted:
|
restricted:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: false
|
example: false
|
||||||
|
/user/{userId}/watchlist:
|
||||||
|
get:
|
||||||
|
summary: Get the Plex watchlist for a specific user
|
||||||
|
description: |
|
||||||
|
Retrieves a user's Plex Watchlist in a JSON object.
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: userId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
- in: query
|
||||||
|
name: page
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
default: 1
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Watchlist data returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
page:
|
||||||
|
type: number
|
||||||
|
totalPages:
|
||||||
|
type: number
|
||||||
|
totalResults:
|
||||||
|
type: number
|
||||||
|
results:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
tmdbId:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
ratingKey:
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
/user/{userId}/settings/main:
|
/user/{userId}/settings/main:
|
||||||
get:
|
get:
|
||||||
summary: Get general settings for a user
|
summary: Get general settings for a user
|
||||||
@@ -3794,6 +4076,86 @@ paths:
|
|||||||
- $ref: '#/components/schemas/MovieResult'
|
- $ref: '#/components/schemas/MovieResult'
|
||||||
- $ref: '#/components/schemas/TvResult'
|
- $ref: '#/components/schemas/TvResult'
|
||||||
- $ref: '#/components/schemas/PersonResult'
|
- $ref: '#/components/schemas/PersonResult'
|
||||||
|
/search/keyword:
|
||||||
|
get:
|
||||||
|
summary: Search for keywords
|
||||||
|
description: Returns a list of TMDB keywords matching the search query
|
||||||
|
tags:
|
||||||
|
- search
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 'christmas'
|
||||||
|
- in: query
|
||||||
|
name: page
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
default: 1
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Results
|
||||||
|
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/Keyword'
|
||||||
|
/search/company:
|
||||||
|
get:
|
||||||
|
summary: Search for companies
|
||||||
|
description: Returns a list of TMDB companies matching the search query. (Will not return origin country)
|
||||||
|
tags:
|
||||||
|
- search
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 'Disney'
|
||||||
|
- in: query
|
||||||
|
name: page
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
default: 1
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Results
|
||||||
|
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/Company'
|
||||||
/discover/movies:
|
/discover/movies:
|
||||||
get:
|
get:
|
||||||
summary: Discover movies
|
summary: Discover movies
|
||||||
@@ -3815,13 +4177,73 @@ paths:
|
|||||||
- in: query
|
- in: query
|
||||||
name: genre
|
name: genre
|
||||||
schema:
|
schema:
|
||||||
type: number
|
type: string
|
||||||
example: 18
|
example: 18
|
||||||
- in: query
|
- in: query
|
||||||
name: studio
|
name: studio
|
||||||
schema:
|
schema:
|
||||||
type: number
|
type: number
|
||||||
example: 1
|
example: 1
|
||||||
|
- in: query
|
||||||
|
name: keywords
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 1,2
|
||||||
|
- in: query
|
||||||
|
name: sortBy
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: popularity.desc
|
||||||
|
- in: query
|
||||||
|
name: primaryReleaseDateGte
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 2022-01-01
|
||||||
|
- in: query
|
||||||
|
name: primaryReleaseDateLte
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 2023-01-01
|
||||||
|
- in: query
|
||||||
|
name: withRuntimeGte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 60
|
||||||
|
- in: query
|
||||||
|
name: withRuntimeLte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 120
|
||||||
|
- in: query
|
||||||
|
name: voteAverageGte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 7
|
||||||
|
- in: query
|
||||||
|
name: voteAverageLte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 10
|
||||||
|
- in: query
|
||||||
|
name: voteCountGte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 7
|
||||||
|
- in: query
|
||||||
|
name: voteCountLte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 10
|
||||||
|
- in: query
|
||||||
|
name: watchRegion
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: US
|
||||||
|
- in: query
|
||||||
|
name: watchProviders
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 8|9
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Results
|
description: Results
|
||||||
@@ -4044,13 +4466,73 @@ paths:
|
|||||||
- in: query
|
- in: query
|
||||||
name: genre
|
name: genre
|
||||||
schema:
|
schema:
|
||||||
type: number
|
type: string
|
||||||
example: 18
|
example: 18
|
||||||
- in: query
|
- in: query
|
||||||
name: network
|
name: network
|
||||||
schema:
|
schema:
|
||||||
type: number
|
type: number
|
||||||
example: 1
|
example: 1
|
||||||
|
- in: query
|
||||||
|
name: keywords
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 1,2
|
||||||
|
- in: query
|
||||||
|
name: sortBy
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: popularity.desc
|
||||||
|
- in: query
|
||||||
|
name: firstAirDateGte
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 2022-01-01
|
||||||
|
- in: query
|
||||||
|
name: firstAirDateLte
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 2023-01-01
|
||||||
|
- in: query
|
||||||
|
name: withRuntimeGte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 60
|
||||||
|
- in: query
|
||||||
|
name: withRuntimeLte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 120
|
||||||
|
- in: query
|
||||||
|
name: voteAverageGte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 7
|
||||||
|
- in: query
|
||||||
|
name: voteAverageLte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 10
|
||||||
|
- in: query
|
||||||
|
name: voteCountGte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 7
|
||||||
|
- in: query
|
||||||
|
name: voteCountLte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 10
|
||||||
|
- in: query
|
||||||
|
name: watchRegion
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: US
|
||||||
|
- in: query
|
||||||
|
name: watchProviders
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 8|9
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Results
|
description: Results
|
||||||
@@ -4403,6 +4885,46 @@ paths:
|
|||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
example: Genre Name
|
example: Genre Name
|
||||||
|
/discover/watchlist:
|
||||||
|
get:
|
||||||
|
summary: Get the Plex watchlist.
|
||||||
|
tags:
|
||||||
|
- search
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: page
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
default: 1
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Watchlist data returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
page:
|
||||||
|
type: number
|
||||||
|
totalPages:
|
||||||
|
type: number
|
||||||
|
totalResults:
|
||||||
|
type: number
|
||||||
|
results:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
tmdbId:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
ratingKey:
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
/request:
|
/request:
|
||||||
get:
|
get:
|
||||||
summary: Get all requests
|
summary: Get all requests
|
||||||
@@ -4492,9 +5014,13 @@ paths:
|
|||||||
type: number
|
type: number
|
||||||
example: 123
|
example: 123
|
||||||
seasons:
|
seasons:
|
||||||
type: array
|
oneOf:
|
||||||
items:
|
- type: array
|
||||||
type: number
|
items:
|
||||||
|
type: number
|
||||||
|
minimum: 0
|
||||||
|
- type: string
|
||||||
|
enum: [all]
|
||||||
is4k:
|
is4k:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: false
|
example: false
|
||||||
@@ -4573,7 +5099,7 @@ paths:
|
|||||||
$ref: '#/components/schemas/MediaRequest'
|
$ref: '#/components/schemas/MediaRequest'
|
||||||
put:
|
put:
|
||||||
summary: Update MediaRequest
|
summary: Update MediaRequest
|
||||||
description: Updates a specific media request and returns the request in a JSON object.. Requires the `MANAGE_REQUESTS` permission.
|
description: Updates a specific media request and returns the request in a JSON object. Requires the `MANAGE_REQUESTS` permission.
|
||||||
tags:
|
tags:
|
||||||
- request
|
- request
|
||||||
parameters:
|
parameters:
|
||||||
@@ -4584,6 +5110,37 @@ paths:
|
|||||||
example: '1'
|
example: '1'
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
mediaType:
|
||||||
|
type: string
|
||||||
|
enum: [movie, tv]
|
||||||
|
seasons:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: number
|
||||||
|
minimum: 0
|
||||||
|
is4k:
|
||||||
|
type: boolean
|
||||||
|
example: false
|
||||||
|
serverId:
|
||||||
|
type: number
|
||||||
|
profileId:
|
||||||
|
type: number
|
||||||
|
rootFolder:
|
||||||
|
type: string
|
||||||
|
languageProfileId:
|
||||||
|
type: number
|
||||||
|
userId:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
required:
|
||||||
|
- mediaType
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Succesfully updated request
|
description: Succesfully updated request
|
||||||
@@ -4654,7 +5211,7 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
enum: [pending, approve, decline, available]
|
enum: [approve, decline]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Request status changed
|
description: Request status changed
|
||||||
@@ -4819,6 +5376,63 @@ paths:
|
|||||||
audienceRating:
|
audienceRating:
|
||||||
type: string
|
type: string
|
||||||
enum: ['Spilled', 'Upright']
|
enum: ['Spilled', 'Upright']
|
||||||
|
/movie/{movieId}/ratingscombined:
|
||||||
|
get:
|
||||||
|
summary: Get RT and IMDB movie ratings combined
|
||||||
|
description: Returns ratings from RottenTomatoes and IMDB based on the provided movieId in a JSON object.
|
||||||
|
tags:
|
||||||
|
- movies
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: movieId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 337401
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Ratings returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
rt:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
example: Mulan
|
||||||
|
year:
|
||||||
|
type: number
|
||||||
|
example: 2020
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
example: 'http://www.rottentomatoes.com/m/mulan_2020/'
|
||||||
|
criticsScore:
|
||||||
|
type: number
|
||||||
|
example: 85
|
||||||
|
criticsRating:
|
||||||
|
type: string
|
||||||
|
enum: ['Rotten', 'Fresh', 'Certified Fresh']
|
||||||
|
audienceScore:
|
||||||
|
type: number
|
||||||
|
example: 65
|
||||||
|
audienceRating:
|
||||||
|
type: string
|
||||||
|
enum: ['Spilled', 'Upright']
|
||||||
|
imdb:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
example: I am Legend
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
example: 'https://www.imdb.com/title/tt0480249'
|
||||||
|
criticsScore:
|
||||||
|
type: number
|
||||||
|
example: 6.5
|
||||||
/tv/{tvId}:
|
/tv/{tvId}:
|
||||||
get:
|
get:
|
||||||
summary: Get TV details
|
summary: Get TV details
|
||||||
@@ -5774,6 +6388,89 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Issue'
|
$ref: '#/components/schemas/Issue'
|
||||||
|
/keyword/{keywordId}:
|
||||||
|
get:
|
||||||
|
summary: Get keyword
|
||||||
|
description: |
|
||||||
|
Returns a single keyword in JSON format.
|
||||||
|
tags:
|
||||||
|
- other
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: keywordId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Keyword returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Keyword'
|
||||||
|
/watchproviders/regions:
|
||||||
|
get:
|
||||||
|
summary: Get watch provider regions
|
||||||
|
description: |
|
||||||
|
Returns a list of all available watch provider regions.
|
||||||
|
tags:
|
||||||
|
- other
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Watch provider regions returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/WatchProviderRegion'
|
||||||
|
/watchproviders/movies:
|
||||||
|
get:
|
||||||
|
summary: Get watch provider movies
|
||||||
|
description: |
|
||||||
|
Returns a list of all available watch providers for movies.
|
||||||
|
tags:
|
||||||
|
- other
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: watchRegion
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: US
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Watch providers for movies returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/WatchProviderDetails'
|
||||||
|
/watchproviders/tv:
|
||||||
|
get:
|
||||||
|
summary: Get watch provider series
|
||||||
|
description: |
|
||||||
|
Returns a list of all available watch providers for series.
|
||||||
|
tags:
|
||||||
|
- other
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: watchRegion
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: US
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Watch providers for series returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/WatchProviderDetails'
|
||||||
security:
|
security:
|
||||||
- cookieAuth: []
|
- cookieAuth: []
|
||||||
- apiKey: []
|
- apiKey: []
|
||||||
|
|||||||
168
package.json
168
package.json
@@ -3,16 +3,16 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node --files --project server/tsconfig.json server/index.ts",
|
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
|
||||||
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates",
|
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json",
|
||||||
"build:next": "next build",
|
"build:next": "next build",
|
||||||
"build": "yarn build:next && yarn build:server",
|
"build": "yarn build:next && yarn build:server",
|
||||||
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache",
|
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache",
|
||||||
"start": "NODE_ENV=production node dist/index.js",
|
"start": "NODE_ENV=production node dist/index.js",
|
||||||
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault true \"./src/**/!(*.test).{ts,tsx}\"",
|
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault true \"./src/**/!(*.test).{ts,tsx}\"",
|
||||||
"migration:generate": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts",
|
"migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts",
|
||||||
"migration:create": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -d server/datasource.ts",
|
"migration:create": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -d server/datasource.ts",
|
||||||
"migration:run": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts",
|
"migration:run": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts",
|
||||||
"format": "prettier --loglevel warn --write --cache .",
|
"format": "prettier --loglevel warn --write --cache .",
|
||||||
"format:check": "prettier --check --cache .",
|
"format:check": "prettier --check --cache .",
|
||||||
"typecheck": "yarn typecheck:server && yarn typecheck:client",
|
"typecheck": "yarn typecheck:server && yarn typecheck:client",
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"typecheck:client": "tsc --noEmit",
|
"typecheck:client": "tsc --noEmit",
|
||||||
"prepare": "husky install",
|
"prepare": "husky install",
|
||||||
"cypress:open": "cypress open",
|
"cypress:open": "cypress open",
|
||||||
"cypress:prepare": "ts-node --files server/scripts/prepareTestDb.ts",
|
"cypress:prepare": "ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/scripts/prepareTestDb.ts",
|
||||||
"cypress:build": "yarn build && yarn cypress:prepare"
|
"cypress:build": "yarn build && yarn cypress:prepare"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -29,137 +29,147 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/intl-displaynames": "6.0.3",
|
"@formatjs/intl-displaynames": "6.2.6",
|
||||||
"@formatjs/intl-locale": "3.0.3",
|
"@formatjs/intl-locale": "3.1.1",
|
||||||
"@formatjs/intl-pluralrules": "5.0.3",
|
"@formatjs/intl-pluralrules": "5.1.10",
|
||||||
"@headlessui/react": "1.6.6",
|
"@formatjs/intl-utils": "3.8.4",
|
||||||
"@heroicons/react": "1.0.6",
|
"@headlessui/react": "1.7.12",
|
||||||
|
"@heroicons/react": "2.0.16",
|
||||||
"@supercharge/request-ip": "1.2.0",
|
"@supercharge/request-ip": "1.2.0",
|
||||||
"@svgr/webpack": "6.3.1",
|
"@svgr/webpack": "6.5.1",
|
||||||
"@tanem/react-nprogress": "5.0.11",
|
"@tanem/react-nprogress": "5.0.30",
|
||||||
"ace-builds": "1.9.6",
|
"ace-builds": "1.15.2",
|
||||||
"axios": "0.27.2",
|
"axios": "1.3.4",
|
||||||
"axios-rate-limit": "1.3.0",
|
"axios-rate-limit": "1.3.0",
|
||||||
"bcrypt": "5.0.1",
|
"bcrypt": "5.1.0",
|
||||||
"bowser": "2.11.0",
|
"bowser": "2.11.0",
|
||||||
"connect-typeorm": "1.1.4",
|
"connect-typeorm": "1.1.4",
|
||||||
"cookie-parser": "1.4.6",
|
"cookie-parser": "1.4.6",
|
||||||
"copy-to-clipboard": "3.3.2",
|
"copy-to-clipboard": "3.3.3",
|
||||||
"country-flag-icons": "1.5.5",
|
"country-flag-icons": "1.5.5",
|
||||||
|
"cronstrue": "2.23.0",
|
||||||
"csurf": "1.11.0",
|
"csurf": "1.11.0",
|
||||||
"date-fns": "2.29.1",
|
"date-fns": "2.29.3",
|
||||||
|
"dayjs": "1.11.7",
|
||||||
"email-templates": "9.0.0",
|
"email-templates": "9.0.0",
|
||||||
"express": "4.18.1",
|
"express": "4.18.2",
|
||||||
"express-openapi-validator": "4.13.8",
|
"express-openapi-validator": "4.13.8",
|
||||||
"express-rate-limit": "6.5.1",
|
"express-rate-limit": "6.7.0",
|
||||||
"express-session": "1.17.3",
|
"express-session": "1.17.3",
|
||||||
"formik": "2.2.9",
|
"formik": "2.2.9",
|
||||||
"gravatar-url": "3.1.0",
|
"gravatar-url": "3.1.0",
|
||||||
"intl": "1.2.5",
|
"intl": "1.2.5",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"next": "12.2.5",
|
"next": "12.3.4",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"node-gyp": "9.1.0",
|
"node-gyp": "9.3.1",
|
||||||
"node-schedule": "2.1.0",
|
"node-schedule": "2.1.1",
|
||||||
"nodemailer": "6.7.8",
|
"nodemailer": "6.9.1",
|
||||||
"openpgp": "5.4.0",
|
"openpgp": "5.7.0",
|
||||||
"plex-api": "5.3.2",
|
"plex-api": "5.3.2",
|
||||||
"pug": "3.0.2",
|
"pug": "3.0.2",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-ace": "10.1.0",
|
"react-ace": "10.1.0",
|
||||||
"react-animate-height": "2.1.2",
|
"react-animate-height": "2.1.2",
|
||||||
|
"react-aria": "3.23.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-intersection-observer": "9.4.0",
|
"react-intersection-observer": "9.4.3",
|
||||||
"react-intl": "6.0.5",
|
"react-intl": "6.2.10",
|
||||||
"react-markdown": "8.0.3",
|
"react-markdown": "8.0.5",
|
||||||
"react-popper-tooltip": "4.4.2",
|
"react-popper-tooltip": "4.4.2",
|
||||||
"react-select": "5.4.0",
|
"react-select": "5.7.0",
|
||||||
"react-spring": "9.5.2",
|
"react-spring": "9.7.1",
|
||||||
|
"react-tailwindcss-datepicker-sct": "1.3.4",
|
||||||
"react-toast-notifications": "2.5.1",
|
"react-toast-notifications": "2.5.1",
|
||||||
"react-transition-group": "4.4.5",
|
|
||||||
"react-truncate-markup": "5.1.2",
|
"react-truncate-markup": "5.1.2",
|
||||||
"react-use-clipboard": "1.0.8",
|
"react-use-clipboard": "1.0.9",
|
||||||
"reflect-metadata": "0.1.13",
|
"reflect-metadata": "0.1.13",
|
||||||
"secure-random-password": "0.2.3",
|
"secure-random-password": "0.2.3",
|
||||||
"semver": "7.3.7",
|
"semver": "7.3.8",
|
||||||
"sqlite3": "5.0.11",
|
"sqlite3": "5.1.4",
|
||||||
"swagger-ui-express": "4.5.0",
|
"swagger-ui-express": "4.6.2",
|
||||||
"swr": "1.3.0",
|
"swr": "2.0.4",
|
||||||
"typeorm": "0.3.7",
|
"typeorm": "0.3.12",
|
||||||
"web-push": "3.5.0",
|
"web-push": "3.5.0",
|
||||||
"winston": "3.8.1",
|
"winston": "3.8.2",
|
||||||
"winston-daily-rotate-file": "4.7.1",
|
"winston-daily-rotate-file": "4.7.1",
|
||||||
"xml2js": "0.4.23",
|
"xml2js": "0.4.23",
|
||||||
"yamljs": "0.3.0",
|
"yamljs": "0.3.0",
|
||||||
"yup": "0.32.11"
|
"yup": "0.32.11",
|
||||||
|
"zod": "3.20.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/cli": "7.18.10",
|
"@babel/cli": "7.21.0",
|
||||||
"@commitlint/cli": "17.0.3",
|
"@commitlint/cli": "17.4.4",
|
||||||
"@commitlint/config-conventional": "17.0.3",
|
"@commitlint/config-conventional": "17.4.4",
|
||||||
"@semantic-release/changelog": "6.0.1",
|
"@semantic-release/changelog": "6.0.2",
|
||||||
"@semantic-release/commit-analyzer": "9.0.2",
|
"@semantic-release/commit-analyzer": "9.0.2",
|
||||||
"@semantic-release/exec": "6.0.3",
|
"@semantic-release/exec": "6.0.3",
|
||||||
"@semantic-release/git": "10.0.1",
|
"@semantic-release/git": "10.0.1",
|
||||||
"@tailwindcss/aspect-ratio": "0.4.0",
|
"@tailwindcss/aspect-ratio": "0.4.2",
|
||||||
"@tailwindcss/forms": "0.5.2",
|
"@tailwindcss/forms": "0.5.3",
|
||||||
"@tailwindcss/typography": "0.5.4",
|
"@tailwindcss/typography": "0.5.9",
|
||||||
"@types/bcrypt": "5.0.0",
|
"@types/bcrypt": "5.0.0",
|
||||||
"@types/cookie-parser": "1.4.3",
|
"@types/cookie-parser": "1.4.3",
|
||||||
"@types/country-flag-icons": "1.2.0",
|
"@types/country-flag-icons": "1.2.0",
|
||||||
"@types/csurf": "1.11.2",
|
"@types/csurf": "1.11.2",
|
||||||
"@types/email-templates": "8.0.4",
|
"@types/email-templates": "8.0.4",
|
||||||
"@types/express": "4.17.13",
|
"@types/express": "4.17.17",
|
||||||
"@types/express-session": "1.17.4",
|
"@types/express-session": "1.17.6",
|
||||||
"@types/lodash": "4.14.183",
|
"@types/lodash": "4.14.191",
|
||||||
"@types/node": "17.0.36",
|
"@types/node": "17.0.36",
|
||||||
"@types/node-schedule": "2.1.0",
|
"@types/node-schedule": "2.1.0",
|
||||||
"@types/nodemailer": "6.4.5",
|
"@types/nodemailer": "6.4.7",
|
||||||
"@types/react": "18.0.17",
|
"@types/react": "18.0.28",
|
||||||
"@types/react-dom": "18.0.6",
|
"@types/react-dom": "18.0.11",
|
||||||
"@types/react-transition-group": "4.4.5",
|
"@types/react-transition-group": "4.4.5",
|
||||||
"@types/secure-random-password": "0.2.1",
|
"@types/secure-random-password": "0.2.1",
|
||||||
"@types/semver": "7.3.12",
|
"@types/semver": "7.3.13",
|
||||||
"@types/swagger-ui-express": "4.1.3",
|
"@types/swagger-ui-express": "4.1.3",
|
||||||
"@types/web-push": "3.3.2",
|
"@types/web-push": "3.3.2",
|
||||||
"@types/xml2js": "0.4.11",
|
"@types/xml2js": "0.4.11",
|
||||||
"@types/yamljs": "0.2.31",
|
"@types/yamljs": "0.2.31",
|
||||||
"@types/yup": "0.29.14",
|
"@types/yup": "0.29.14",
|
||||||
"@typescript-eslint/eslint-plugin": "5.33.1",
|
"@typescript-eslint/eslint-plugin": "5.54.0",
|
||||||
"@typescript-eslint/parser": "5.33.1",
|
"@typescript-eslint/parser": "5.54.0",
|
||||||
"autoprefixer": "10.4.8",
|
"autoprefixer": "10.4.13",
|
||||||
"babel-plugin-react-intl": "8.2.25",
|
"babel-plugin-react-intl": "8.2.25",
|
||||||
"babel-plugin-react-intl-auto": "3.3.0",
|
"babel-plugin-react-intl-auto": "3.3.0",
|
||||||
"commitizen": "4.2.5",
|
"commitizen": "4.3.0",
|
||||||
"copyfiles": "2.4.1",
|
"copyfiles": "2.4.1",
|
||||||
"cypress": "10.6.0",
|
"cy-mobile-commands": "0.3.0",
|
||||||
|
"cypress": "12.7.0",
|
||||||
"cz-conventional-changelog": "3.3.0",
|
"cz-conventional-changelog": "3.3.0",
|
||||||
"eslint": "8.22.0",
|
"eslint": "8.35.0",
|
||||||
"eslint-config-next": "12.2.5",
|
"eslint-config-next": "12.3.4",
|
||||||
"eslint-config-prettier": "8.5.0",
|
"eslint-config-prettier": "8.6.0",
|
||||||
"eslint-plugin-formatjs": "4.1.0",
|
"eslint-plugin-formatjs": "4.9.0",
|
||||||
"eslint-plugin-jsx-a11y": "6.6.1",
|
"eslint-plugin-jsx-a11y": "6.7.1",
|
||||||
|
"eslint-plugin-no-relative-import-paths": "1.5.2",
|
||||||
"eslint-plugin-prettier": "4.2.1",
|
"eslint-plugin-prettier": "4.2.1",
|
||||||
"eslint-plugin-react": "7.30.1",
|
"eslint-plugin-react": "7.32.2",
|
||||||
"eslint-plugin-react-hooks": "4.6.0",
|
"eslint-plugin-react-hooks": "4.6.0",
|
||||||
"extract-react-intl-messages": "4.1.1",
|
"extract-react-intl-messages": "4.1.1",
|
||||||
"husky": "8.0.1",
|
"husky": "8.0.3",
|
||||||
"lint-staged": "12.4.3",
|
"lint-staged": "13.1.2",
|
||||||
"nodemon": "2.0.19",
|
"nodemon": "2.0.20",
|
||||||
"postcss": "8.4.16",
|
"postcss": "8.4.21",
|
||||||
"prettier": "2.7.1",
|
"prettier": "2.8.4",
|
||||||
"prettier-plugin-organize-imports": "3.1.0",
|
"prettier-plugin-organize-imports": "3.2.2",
|
||||||
"prettier-plugin-tailwindcss": "0.1.13",
|
"prettier-plugin-tailwindcss": "0.2.3",
|
||||||
"semantic-release": "19.0.3",
|
"semantic-release": "19.0.5",
|
||||||
"semantic-release-docker-buildx": "1.0.1",
|
"semantic-release-docker-buildx": "1.0.1",
|
||||||
"tailwindcss": "3.1.8",
|
"tailwindcss": "3.2.7",
|
||||||
"ts-node": "10.9.1",
|
"ts-node": "10.9.1",
|
||||||
"typescript": "4.7.4"
|
"tsc-alias": "1.8.2",
|
||||||
|
"tsconfig-paths": "4.1.2",
|
||||||
|
"typescript": "4.9.5"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"sqlite3/node-gyp": "8.4.1",
|
"sqlite3/node-gyp": "8.4.1",
|
||||||
"@types/react": "18.0.17",
|
"@types/react": "18.0.28",
|
||||||
"@types/react-dom": "18.0.6"
|
"@types/react-dom": "18.0.11",
|
||||||
|
"@types/express-session": "1.17.6"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"commitizen": {
|
"commitizen": {
|
||||||
@@ -180,10 +190,6 @@
|
|||||||
"@commitlint/config-conventional"
|
"@commitlint/config-conventional"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"prettier": {
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "es5"
|
|
||||||
},
|
|
||||||
"release": {
|
"release": {
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"@semantic-release/commit-analyzer",
|
"@semantic-release/commit-analyzer",
|
||||||
@@ -221,7 +227,7 @@
|
|||||||
{
|
{
|
||||||
"path": "semantic-release-docker-buildx",
|
"path": "semantic-release-docker-buildx",
|
||||||
"buildArgs": {
|
"buildArgs": {
|
||||||
"COMMIT_TAG": "$GITHUB_SHA"
|
"COMMIT_TAG": "$GIT_SHA"
|
||||||
},
|
},
|
||||||
"imageNames": [
|
"imageNames": [
|
||||||
"sctx/overseerr",
|
"sctx/overseerr",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
color: #6366F1;
|
color: #6366f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
<!-- Inline the page's JavaScript file. -->
|
<!-- Inline the page's JavaScript file. -->
|
||||||
<script>
|
<script>
|
||||||
// Manual reload feature.
|
// Manual reload feature.
|
||||||
document.querySelector("button").addEventListener("click", () => {
|
document.querySelector('button').addEventListener('click', () => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
74
public/sw.js
74
public/sw.js
@@ -4,30 +4,30 @@
|
|||||||
// This variable is intentionally declared and unused.
|
// This variable is intentionally declared and unused.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const OFFLINE_VERSION = 3;
|
const OFFLINE_VERSION = 3;
|
||||||
const CACHE_NAME = "offline";
|
const CACHE_NAME = 'offline';
|
||||||
// Customize this with a different URL if needed.
|
// Customize this with a different URL if needed.
|
||||||
const OFFLINE_URL = "/offline.html";
|
const OFFLINE_URL = '/offline.html';
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
(async () => {
|
(async () => {
|
||||||
const cache = await caches.open(CACHE_NAME);
|
const cache = await caches.open(CACHE_NAME);
|
||||||
// Setting {cache: 'reload'} in the new request will ensure that the
|
// Setting {cache: 'reload'} in the new request will ensure that the
|
||||||
// response isn't fulfilled from the HTTP cache; i.e., it will be from
|
// response isn't fulfilled from the HTTP cache; i.e., it will be from
|
||||||
// the network.
|
// the network.
|
||||||
await cache.add(new Request(OFFLINE_URL, { cache: "reload" }));
|
await cache.add(new Request(OFFLINE_URL, { cache: 'reload' }));
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
// Force the waiting service worker to become the active service worker.
|
// Force the waiting service worker to become the active service worker.
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("activate", (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
(async () => {
|
(async () => {
|
||||||
// Enable navigation preload if it's supported.
|
// Enable navigation preload if it's supported.
|
||||||
// See https://developers.google.com/web/updates/2017/02/navigation-preload
|
// See https://developers.google.com/web/updates/2017/02/navigation-preload
|
||||||
if ("navigationPreload" in self.registration) {
|
if ('navigationPreload' in self.registration) {
|
||||||
await self.registration.navigationPreload.enable();
|
await self.registration.navigationPreload.enable();
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
@@ -37,10 +37,10 @@ self.addEventListener("activate", (event) => {
|
|||||||
clients.claim();
|
clients.claim();
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
// We only want to call event.respondWith() if this is a navigation request
|
// We only want to call event.respondWith() if this is a navigation request
|
||||||
// for an HTML page.
|
// for an HTML page.
|
||||||
if (event.request.mode === "navigate") {
|
if (event.request.mode === 'navigate') {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -59,7 +59,7 @@ self.addEventListener("fetch", (event) => {
|
|||||||
// If fetch() returns a valid HTTP response with a response code in
|
// If fetch() returns a valid HTTP response with a response code in
|
||||||
// the 4xx or 5xx range, the catch() will NOT be called.
|
// the 4xx or 5xx range, the catch() will NOT be called.
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log("Fetch failed; returning offline page instead.", error);
|
console.log('Fetch failed; returning offline page instead.', error);
|
||||||
|
|
||||||
const cache = await caches.open(CACHE_NAME);
|
const cache = await caches.open(CACHE_NAME);
|
||||||
const cachedResponse = await cache.match(OFFLINE_URL);
|
const cachedResponse = await cache.match(OFFLINE_URL);
|
||||||
@@ -85,15 +85,13 @@ self.addEventListener('push', (event) => {
|
|||||||
requestId: payload.requestId,
|
requestId: payload.requestId,
|
||||||
},
|
},
|
||||||
actions: [],
|
actions: [],
|
||||||
}
|
};
|
||||||
|
|
||||||
if (payload.actionUrl){
|
if (payload.actionUrl) {
|
||||||
options.actions.push(
|
options.actions.push({
|
||||||
{
|
action: 'view',
|
||||||
action: 'view',
|
title: payload.actionUrlTitle ?? 'View',
|
||||||
title: payload.actionUrlTitle ?? 'View',
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.notificationType === 'MEDIA_PENDING') {
|
if (payload.notificationType === 'MEDIA_PENDING') {
|
||||||
@@ -109,27 +107,29 @@ self.addEventListener('push', (event) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(self.registration.showNotification(payload.subject, options));
|
||||||
self.registration.showNotification(payload.subject, options)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('notificationclick', (event) => {
|
self.addEventListener(
|
||||||
const notificationData = event.notification.data;
|
'notificationclick',
|
||||||
|
(event) => {
|
||||||
|
const notificationData = event.notification.data;
|
||||||
|
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
|
|
||||||
if (event.action === 'approve') {
|
if (event.action === 'approve') {
|
||||||
fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
|
fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
} else if (event.action === 'decline') {
|
} else if (event.action === 'decline') {
|
||||||
fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
|
fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notificationData.actionUrl) {
|
if (notificationData.actionUrl) {
|
||||||
clients.openWindow(notificationData.actionUrl);
|
clients.openWindow(notificationData.actionUrl);
|
||||||
}
|
}
|
||||||
}, false);
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import logger from '@server/logger';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import xml2js from 'xml2js';
|
|
||||||
import fs, { promises as fsp } from 'fs';
|
import fs, { promises as fsp } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import logger from '../logger';
|
import xml2js from 'xml2js';
|
||||||
|
|
||||||
const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds
|
const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds
|
||||||
// originally at https://raw.githubusercontent.com/ScudLee/anime-lists/master/anime-list.xml
|
// originally at https://raw.githubusercontent.com/ScudLee/anime-lists/master/anime-list.xml
|
||||||
|
|||||||
@@ -69,6 +69,30 @@ class ExternalAPI {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async post<T>(
|
||||||
|
endpoint: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
config?: AxiosRequestConfig,
|
||||||
|
ttl?: number
|
||||||
|
): Promise<T> {
|
||||||
|
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||||
|
config: config?.params,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||||
|
if (cachedItem) {
|
||||||
|
return cachedItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.axios.post<T>(endpoint, data, config);
|
||||||
|
|
||||||
|
if (this.cache) {
|
||||||
|
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
protected async getRolling<T>(
|
protected async getRolling<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
config?: AxiosRequestConfig,
|
config?: AxiosRequestConfig,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import cacheManager from '../lib/cache';
|
import cacheManager from '@server/lib/cache';
|
||||||
import logger from '../logger';
|
import logger from '@server/logger';
|
||||||
import ExternalAPI from './externalapi';
|
import ExternalAPI from './externalapi';
|
||||||
|
|
||||||
interface GitHubRelease {
|
interface GitHubRelease {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import type { Library, PlexSettings } from '@server/lib/settings';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
import NodePlexAPI from 'plex-api';
|
import NodePlexAPI from 'plex-api';
|
||||||
import type { Library, PlexSettings } from '../lib/settings';
|
|
||||||
import { getSettings } from '../lib/settings';
|
|
||||||
import logger from '../logger';
|
|
||||||
|
|
||||||
export interface PlexLibraryItem {
|
export interface PlexLibraryItem {
|
||||||
ratingKey: string;
|
ratingKey: string;
|
||||||
@@ -226,12 +226,17 @@ class PlexAPI {
|
|||||||
id: string,
|
id: string,
|
||||||
options: { addedAt: number } = {
|
options: { addedAt: number } = {
|
||||||
addedAt: Date.now() - 1000 * 60 * 60,
|
addedAt: Date.now() - 1000 * 60 * 60,
|
||||||
}
|
},
|
||||||
|
mediaType: 'movie' | 'show'
|
||||||
): Promise<PlexLibraryItem[]> {
|
): Promise<PlexLibraryItem[]> {
|
||||||
const response = await this.plexClient.query<PlexLibraryResponse>({
|
const response = await this.plexClient.query<PlexLibraryResponse>({
|
||||||
uri: `/library/sections/${id}/all?sort=addedAt%3Adesc&addedAt>>=${Math.floor(
|
uri: `/library/sections/${id}/all?type=${
|
||||||
options.addedAt / 1000
|
mediaType === 'show' ? '4' : '1'
|
||||||
)}`,
|
}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`,
|
||||||
|
extraHeaders: {
|
||||||
|
'X-Plex-Container-Start': `0`,
|
||||||
|
'X-Plex-Container-Size': `500`,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.MediaContainer.Metadata;
|
return response.MediaContainer.Metadata;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { AxiosInstance } from 'axios';
|
import type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
|
||||||
import axios from 'axios';
|
import cacheManager from '@server/lib/cache';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import xml2js from 'xml2js';
|
import xml2js from 'xml2js';
|
||||||
import type { PlexDevice } from '../interfaces/api/plexInterfaces';
|
import ExternalAPI from './externalapi';
|
||||||
import { getSettings } from '../lib/settings';
|
|
||||||
import logger from '../logger';
|
|
||||||
|
|
||||||
interface PlexAccountResponse {
|
interface PlexAccountResponse {
|
||||||
user: PlexUser;
|
user: PlexUser;
|
||||||
@@ -82,21 +83,6 @@ interface ServerResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FriendResponse {
|
|
||||||
MediaContainer: {
|
|
||||||
User: {
|
|
||||||
$: {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
thumb: string;
|
|
||||||
};
|
|
||||||
Server?: ServerResponse[];
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UsersResponse {
|
interface UsersResponse {
|
||||||
MediaContainer: {
|
MediaContainer: {
|
||||||
User: {
|
User: {
|
||||||
@@ -112,20 +98,59 @@ interface UsersResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class PlexTvAPI {
|
interface WatchlistResponse {
|
||||||
|
MediaContainer: {
|
||||||
|
totalSize: number;
|
||||||
|
Metadata?: {
|
||||||
|
ratingKey: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetadataResponse {
|
||||||
|
MediaContainer: {
|
||||||
|
Metadata: {
|
||||||
|
ratingKey: string;
|
||||||
|
type: 'movie' | 'show';
|
||||||
|
title: string;
|
||||||
|
Guid: {
|
||||||
|
id: `imdb://tt${number}` | `tmdb://${number}` | `tvdb://${number}`;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlexWatchlistItem {
|
||||||
|
ratingKey: string;
|
||||||
|
tmdbId: number;
|
||||||
|
tvdbId?: number;
|
||||||
|
type: 'movie' | 'show';
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlexWatchlistCache {
|
||||||
|
etag: string;
|
||||||
|
response: WatchlistResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlexTvAPI extends ExternalAPI {
|
||||||
private authToken: string;
|
private authToken: string;
|
||||||
private axios: AxiosInstance;
|
|
||||||
|
|
||||||
constructor(authToken: string) {
|
constructor(authToken: string) {
|
||||||
|
super(
|
||||||
|
'https://plex.tv',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'X-Plex-Token': authToken,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
nodeCache: cacheManager.getCache('plextv').data,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
this.authToken = authToken;
|
this.authToken = authToken;
|
||||||
this.axios = axios.create({
|
|
||||||
baseURL: 'https://plex.tv',
|
|
||||||
headers: {
|
|
||||||
'X-Plex-Token': this.authToken,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getDevices(): Promise<PlexDevice[]> {
|
public async getDevices(): Promise<PlexDevice[]> {
|
||||||
@@ -200,19 +225,6 @@ class PlexTvAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getFriends(): Promise<FriendResponse> {
|
|
||||||
const response = await this.axios.get('/pms/friends/all', {
|
|
||||||
transformResponse: [],
|
|
||||||
responseType: 'text',
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsedXml = (await xml2js.parseStringPromise(
|
|
||||||
response.data
|
|
||||||
)) as FriendResponse;
|
|
||||||
|
|
||||||
return parsedXml;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async checkUserAccess(userId: number): Promise<boolean> {
|
public async checkUserAccess(userId: number): Promise<boolean> {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
@@ -221,9 +233,9 @@ class PlexTvAPI {
|
|||||||
throw new Error('Plex is not configured!');
|
throw new Error('Plex is not configured!');
|
||||||
}
|
}
|
||||||
|
|
||||||
const friends = await this.getFriends();
|
const usersResponse = await this.getUsers();
|
||||||
|
|
||||||
const users = friends.MediaContainer.User;
|
const users = usersResponse.MediaContainer.User;
|
||||||
|
|
||||||
const user = users.find((u) => parseInt(u.$.id) === userId);
|
const user = users.find((u) => parseInt(u.$.id) === userId);
|
||||||
|
|
||||||
@@ -253,6 +265,123 @@ class PlexTvAPI {
|
|||||||
)) as UsersResponse;
|
)) as UsersResponse;
|
||||||
return parsedXml;
|
return parsedXml;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getWatchlist({
|
||||||
|
offset = 0,
|
||||||
|
size = 20,
|
||||||
|
}: { offset?: number; size?: number } = {}): Promise<{
|
||||||
|
offset: number;
|
||||||
|
size: number;
|
||||||
|
totalSize: number;
|
||||||
|
items: PlexWatchlistItem[];
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const watchlistCache = cacheManager.getCache('plexwatchlist');
|
||||||
|
let cachedWatchlist = watchlistCache.data.get<PlexWatchlistCache>(
|
||||||
|
this.authToken
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await this.axios.get<WatchlistResponse>(
|
||||||
|
'/library/sections/watchlist/all',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
'X-Plex-Container-Start': offset,
|
||||||
|
'X-Plex-Container-Size': size,
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'If-None-Match': cachedWatchlist?.etag,
|
||||||
|
},
|
||||||
|
baseURL: 'https://metadata.provider.plex.tv',
|
||||||
|
validateStatus: (status) => status < 400, // Allow HTTP 304 to return without error
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// If we don't recieve HTTP 304, the watchlist has been updated and we need to update the cache.
|
||||||
|
if (response.status >= 200 && response.status <= 299) {
|
||||||
|
cachedWatchlist = {
|
||||||
|
etag: response.headers.etag,
|
||||||
|
response: response.data,
|
||||||
|
};
|
||||||
|
|
||||||
|
watchlistCache.data.set<PlexWatchlistCache>(
|
||||||
|
this.authToken,
|
||||||
|
cachedWatchlist
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchlistDetails = await Promise.all(
|
||||||
|
(cachedWatchlist?.response.MediaContainer.Metadata ?? []).map(
|
||||||
|
async (watchlistItem) => {
|
||||||
|
const detailedResponse = await this.getRolling<MetadataResponse>(
|
||||||
|
`/library/metadata/${watchlistItem.ratingKey}`,
|
||||||
|
{
|
||||||
|
baseURL: 'https://metadata.provider.plex.tv',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const metadata = detailedResponse.MediaContainer.Metadata[0];
|
||||||
|
|
||||||
|
const tmdbString = metadata.Guid.find((guid) =>
|
||||||
|
guid.id.startsWith('tmdb')
|
||||||
|
);
|
||||||
|
const tvdbString = metadata.Guid.find((guid) =>
|
||||||
|
guid.id.startsWith('tvdb')
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ratingKey: metadata.ratingKey,
|
||||||
|
// This should always be set? But I guess it also cannot be?
|
||||||
|
// We will filter out the 0's afterwards
|
||||||
|
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
|
||||||
|
tvdbId: tvdbString
|
||||||
|
? Number(tvdbString.id.split('//')[1])
|
||||||
|
: undefined,
|
||||||
|
title: metadata.title,
|
||||||
|
type: metadata.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
offset,
|
||||||
|
size,
|
||||||
|
totalSize: cachedWatchlist?.response.MediaContainer.totalSize ?? 0,
|
||||||
|
items: filteredList,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to retrieve watchlist items', {
|
||||||
|
label: 'Plex.TV Metadata API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
offset,
|
||||||
|
size,
|
||||||
|
totalSize: 0,
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async pingToken() {
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get('/api/v2/ping', {
|
||||||
|
headers: {
|
||||||
|
'X-Plex-Client-Identifier': randomUUID(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response?.data?.pong) {
|
||||||
|
throw new Error('No pong response');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to ping token', {
|
||||||
|
label: 'Plex Refresh Token',
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PlexTvAPI;
|
export default PlexTvAPI;
|
||||||
|
|||||||
56
server/api/pushover.ts
Normal file
56
server/api/pushover.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import ExternalAPI from './externalapi';
|
||||||
|
|
||||||
|
interface PushoverSoundsResponse {
|
||||||
|
sounds: {
|
||||||
|
[name: string]: string;
|
||||||
|
};
|
||||||
|
status: number;
|
||||||
|
request: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushoverSound {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mapSounds = (sounds: {
|
||||||
|
[name: string]: string;
|
||||||
|
}): PushoverSound[] =>
|
||||||
|
Object.entries(sounds).map(
|
||||||
|
([name, description]) =>
|
||||||
|
({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
} as PushoverSound)
|
||||||
|
);
|
||||||
|
|
||||||
|
class PushoverAPI extends ExternalAPI {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
'https://api.pushover.net/1',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSounds(appToken: string): Promise<PushoverSound[]> {
|
||||||
|
try {
|
||||||
|
const data = await this.get<PushoverSoundsResponse>('/sounds.json', {
|
||||||
|
params: {
|
||||||
|
token: appToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapSounds(data.sounds);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[Pushover] Failed to retrieve sounds: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PushoverAPI;
|
||||||
195
server/api/rating/imdbRadarrProxy.ts
Normal file
195
server/api/rating/imdbRadarrProxy.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
|
import cacheManager from '@server/lib/cache';
|
||||||
|
|
||||||
|
type IMDBRadarrProxyResponse = IMDBMovie[];
|
||||||
|
|
||||||
|
interface IMDBMovie {
|
||||||
|
ImdbId: string;
|
||||||
|
Overview: string;
|
||||||
|
Title: string;
|
||||||
|
OriginalTitle: string;
|
||||||
|
TitleSlug: string;
|
||||||
|
Ratings: Rating[];
|
||||||
|
MovieRatings: MovieRatings;
|
||||||
|
Runtime: number;
|
||||||
|
Images: Image[];
|
||||||
|
Genres: string[];
|
||||||
|
Popularity: number;
|
||||||
|
Premier: string;
|
||||||
|
InCinema: string;
|
||||||
|
PhysicalRelease: any;
|
||||||
|
DigitalRelease: string;
|
||||||
|
Year: number;
|
||||||
|
AlternativeTitles: AlternativeTitle[];
|
||||||
|
Translations: Translation[];
|
||||||
|
Recommendations: Recommendation[];
|
||||||
|
Credits: Credits;
|
||||||
|
Studio: string;
|
||||||
|
YoutubeTrailerId: string;
|
||||||
|
Certifications: Certification[];
|
||||||
|
Status: any;
|
||||||
|
Collection: Collection;
|
||||||
|
OriginalLanguage: string;
|
||||||
|
Homepage: string;
|
||||||
|
TmdbId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Rating {
|
||||||
|
Count: number;
|
||||||
|
Value: number;
|
||||||
|
Origin: string;
|
||||||
|
Type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MovieRatings {
|
||||||
|
Tmdb: Tmdb;
|
||||||
|
Imdb: Imdb;
|
||||||
|
Metacritic: Metacritic;
|
||||||
|
RottenTomatoes: RottenTomatoes;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Tmdb {
|
||||||
|
Count: number;
|
||||||
|
Value: number;
|
||||||
|
Type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Imdb {
|
||||||
|
Count: number;
|
||||||
|
Value: number;
|
||||||
|
Type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Metacritic {
|
||||||
|
Count: number;
|
||||||
|
Value: number;
|
||||||
|
Type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RottenTomatoes {
|
||||||
|
Count: number;
|
||||||
|
Value: number;
|
||||||
|
Type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Image {
|
||||||
|
CoverType: string;
|
||||||
|
Url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlternativeTitle {
|
||||||
|
Title: string;
|
||||||
|
Type: string;
|
||||||
|
Language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Translation {
|
||||||
|
Title: string;
|
||||||
|
Overview: string;
|
||||||
|
Language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Recommendation {
|
||||||
|
TmdbId: number;
|
||||||
|
Title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Credits {
|
||||||
|
Cast: Cast[];
|
||||||
|
Crew: Crew[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Cast {
|
||||||
|
Name: string;
|
||||||
|
Order: number;
|
||||||
|
Character: string;
|
||||||
|
TmdbId: number;
|
||||||
|
CreditId: string;
|
||||||
|
Images: Image2[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Image2 {
|
||||||
|
CoverType: string;
|
||||||
|
Url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Crew {
|
||||||
|
Name: string;
|
||||||
|
Job: string;
|
||||||
|
Department: string;
|
||||||
|
TmdbId: number;
|
||||||
|
CreditId: string;
|
||||||
|
Images: Image3[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Image3 {
|
||||||
|
CoverType: string;
|
||||||
|
Url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Certification {
|
||||||
|
Country: string;
|
||||||
|
Certification: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Collection {
|
||||||
|
Name: string;
|
||||||
|
Images: any;
|
||||||
|
Overview: any;
|
||||||
|
Translations: any;
|
||||||
|
Parts: any;
|
||||||
|
TmdbId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMDBRating {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
criticsScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a best-effort API. The IMDB API is technically
|
||||||
|
* private and getting access costs money/requires approval.
|
||||||
|
*
|
||||||
|
* Radarr hosts a public proxy that's in use by all Radarr instances.
|
||||||
|
*/
|
||||||
|
class IMDBRadarrProxy extends ExternalAPI {
|
||||||
|
constructor() {
|
||||||
|
super('https://api.radarr.video/v1', {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
nodeCache: cacheManager.getCache('imdb').data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the Radarr IMDB Proxy for the movie
|
||||||
|
*
|
||||||
|
* @param IMDBid Id of IMDB movie
|
||||||
|
*/
|
||||||
|
public async getMovieRatings(IMDBid: string): Promise<IMDBRating | null> {
|
||||||
|
try {
|
||||||
|
const data = await this.get<IMDBRadarrProxyResponse>(
|
||||||
|
`/movie/imdb/${IMDBid}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data?.length || data[0].ImdbId !== IMDBid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: data[0].Title,
|
||||||
|
url: `https://www.imdb.com/title/${data[0].ImdbId}`,
|
||||||
|
criticsScore: data[0].MovieRatings.Imdb.Value,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`[IMDB RADARR PROXY API] Failed to retrieve movie ratings: ${e.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IMDBRadarrProxy;
|
||||||
209
server/api/rating/rottentomatoes.ts
Normal file
209
server/api/rating/rottentomatoes.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
|
import cacheManager from '@server/lib/cache';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
|
||||||
|
interface RTAlgoliaSearchResponse {
|
||||||
|
results: {
|
||||||
|
hits: RTAlgoliaHit[];
|
||||||
|
index: 'content_rt' | 'people_rt';
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RTAlgoliaHit {
|
||||||
|
emsId: string;
|
||||||
|
emsVersionId: string;
|
||||||
|
tmsId: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
titles: string[];
|
||||||
|
description: string;
|
||||||
|
releaseYear: number;
|
||||||
|
rating: string;
|
||||||
|
genres: string[];
|
||||||
|
updateDate: string;
|
||||||
|
isEmsSearchable: boolean;
|
||||||
|
rtId: number;
|
||||||
|
vanity: string;
|
||||||
|
aka: string[];
|
||||||
|
posterImageUrl: string;
|
||||||
|
rottenTomatoes: {
|
||||||
|
audienceScore: number;
|
||||||
|
criticsIconUrl: string;
|
||||||
|
wantToSeeCount: number;
|
||||||
|
audienceIconUrl: string;
|
||||||
|
scoreSentiment: string;
|
||||||
|
certifiedFresh: boolean;
|
||||||
|
criticsScore: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RTRating {
|
||||||
|
title: string;
|
||||||
|
year: number;
|
||||||
|
criticsRating: 'Certified Fresh' | 'Fresh' | 'Rotten';
|
||||||
|
criticsScore: number;
|
||||||
|
audienceRating?: 'Upright' | 'Spilled';
|
||||||
|
audienceScore?: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a best-effort API. The Rotten Tomatoes API is technically
|
||||||
|
* private and getting access costs money/requires approval.
|
||||||
|
*
|
||||||
|
* They do, however, have a "public" api that they use to request the
|
||||||
|
* data on their own site. We use this to get ratings for movies/tv shows.
|
||||||
|
*
|
||||||
|
* Unfortunately, we need to do it by searching for the movie name, so it's
|
||||||
|
* not always accurate.
|
||||||
|
*/
|
||||||
|
class RottenTomatoes extends ExternalAPI {
|
||||||
|
constructor() {
|
||||||
|
const settings = getSettings();
|
||||||
|
super(
|
||||||
|
'https://79frdp12pn-dsn.algolia.net/1/indexes/*',
|
||||||
|
{
|
||||||
|
'x-algolia-agent':
|
||||||
|
'Algolia%20for%20JavaScript%20(4.14.3)%3B%20Browser%20(lite)',
|
||||||
|
'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561',
|
||||||
|
'x-algolia-application-id': '79FRDP12PN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
'x-algolia-usertoken': settings.clientId,
|
||||||
|
},
|
||||||
|
nodeCache: cacheManager.getCache('rt').data,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search the RT algolia api for the movie title
|
||||||
|
*
|
||||||
|
* We compare the release date to make sure its the correct
|
||||||
|
* match. But it's not guaranteed to have results.
|
||||||
|
*
|
||||||
|
* @param name Movie name
|
||||||
|
* @param year Release Year
|
||||||
|
*/
|
||||||
|
public async getMovieRatings(
|
||||||
|
name: string,
|
||||||
|
year: number
|
||||||
|
): Promise<RTRating | null> {
|
||||||
|
try {
|
||||||
|
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
|
||||||
|
requests: [
|
||||||
|
{
|
||||||
|
indexName: 'content_rt',
|
||||||
|
query: name,
|
||||||
|
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentResults = data.results.find((r) => r.index === 'content_rt');
|
||||||
|
|
||||||
|
if (!contentResults) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, attempt to match exact name and year
|
||||||
|
let movie = contentResults.hits.find(
|
||||||
|
(movie) => movie.releaseYear === year && movie.title === name
|
||||||
|
);
|
||||||
|
|
||||||
|
// If we don't find a movie, try to match partial name and year
|
||||||
|
if (!movie) {
|
||||||
|
movie = contentResults.hits.find(
|
||||||
|
(movie) => movie.releaseYear === year && movie.title.includes(name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we still dont find a movie, try to match just on year
|
||||||
|
if (!movie) {
|
||||||
|
movie = contentResults.hits.find((movie) => movie.releaseYear === year);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One last try, try exact name match only
|
||||||
|
if (!movie) {
|
||||||
|
movie = contentResults.hits.find((movie) => movie.title === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!movie) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: movie.title,
|
||||||
|
url: `https://www.rottentomatoes.com/m/${movie.vanity}`,
|
||||||
|
criticsRating: movie.rottenTomatoes.certifiedFresh
|
||||||
|
? 'Certified Fresh'
|
||||||
|
: movie.rottenTomatoes.criticsScore >= 60
|
||||||
|
? 'Fresh'
|
||||||
|
: 'Rotten',
|
||||||
|
criticsScore: movie.rottenTomatoes.criticsScore,
|
||||||
|
audienceRating:
|
||||||
|
movie.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled',
|
||||||
|
audienceScore: movie.rottenTomatoes.audienceScore,
|
||||||
|
year: Number(movie.releaseYear),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`[RT API] Failed to retrieve movie ratings: ${e.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTVRatings(
|
||||||
|
name: string,
|
||||||
|
year?: number
|
||||||
|
): Promise<RTRating | null> {
|
||||||
|
try {
|
||||||
|
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
|
||||||
|
requests: [
|
||||||
|
{
|
||||||
|
indexName: 'content_rt',
|
||||||
|
query: name,
|
||||||
|
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentResults = data.results.find((r) => r.index === 'content_rt');
|
||||||
|
|
||||||
|
if (!contentResults) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tvshow: RTAlgoliaHit | undefined = contentResults.hits[0];
|
||||||
|
|
||||||
|
if (year) {
|
||||||
|
tvshow = contentResults.hits.find(
|
||||||
|
(series) => series.releaseYear === year
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tvshow) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: tvshow.title,
|
||||||
|
url: `https://www.rottentomatoes.com/tv/${tvshow.vanity}`,
|
||||||
|
criticsRating:
|
||||||
|
tvshow.rottenTomatoes.criticsScore >= 60 ? 'Fresh' : 'Rotten',
|
||||||
|
criticsScore: tvshow.rottenTomatoes.criticsScore,
|
||||||
|
audienceRating:
|
||||||
|
tvshow.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled',
|
||||||
|
audienceScore: tvshow.rottenTomatoes.audienceScore,
|
||||||
|
year: Number(tvshow.releaseYear),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[RT API] Failed to retrieve tv ratings: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RottenTomatoes;
|
||||||
7
server/api/ratings.ts
Normal file
7
server/api/ratings.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { type IMDBRating } from '@server/api/rating/imdbRadarrProxy';
|
||||||
|
import { type RTRating } from '@server/api/rating/rottentomatoes';
|
||||||
|
|
||||||
|
export interface RatingResponse {
|
||||||
|
rt?: RTRating;
|
||||||
|
imdb?: IMDBRating;
|
||||||
|
}
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import cacheManager from '../lib/cache';
|
|
||||||
import ExternalAPI from './externalapi';
|
|
||||||
|
|
||||||
interface RTSearchResult {
|
|
||||||
meterClass: 'certified_fresh' | 'fresh' | 'rotten';
|
|
||||||
meterScore: number;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RTTvSearchResult extends RTSearchResult {
|
|
||||||
title: string;
|
|
||||||
startYear: number;
|
|
||||||
endYear: number;
|
|
||||||
}
|
|
||||||
interface RTMovieSearchResult extends RTSearchResult {
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
year: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RTMultiSearchResponse {
|
|
||||||
tvCount: number;
|
|
||||||
tvSeries: RTTvSearchResult[];
|
|
||||||
movieCount: number;
|
|
||||||
movies: RTMovieSearchResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RTRating {
|
|
||||||
title: string;
|
|
||||||
year: number;
|
|
||||||
criticsRating: 'Certified Fresh' | 'Fresh' | 'Rotten';
|
|
||||||
criticsScore: number;
|
|
||||||
audienceRating?: 'Upright' | 'Spilled';
|
|
||||||
audienceScore?: number;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is a best-effort API. The Rotten Tomatoes API is technically
|
|
||||||
* private and getting access costs money/requires approval.
|
|
||||||
*
|
|
||||||
* They do, however, have a "public" api that they use to request the
|
|
||||||
* data on their own site. We use this to get ratings for movies/tv shows.
|
|
||||||
*
|
|
||||||
* Unfortunately, we need to do it by searching for the movie name, so it's
|
|
||||||
* not always accurate.
|
|
||||||
*/
|
|
||||||
class RottenTomatoes extends ExternalAPI {
|
|
||||||
constructor() {
|
|
||||||
super(
|
|
||||||
'https://www.rottentomatoes.com/api/private',
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
nodeCache: cacheManager.getCache('rt').data,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search the 1.0 api for the movie title
|
|
||||||
*
|
|
||||||
* We compare the release date to make sure its the correct
|
|
||||||
* match. But it's not guaranteed to have results.
|
|
||||||
*
|
|
||||||
* We use the 1.0 API here because the 2.0 search api does
|
|
||||||
* not return audience ratings.
|
|
||||||
*
|
|
||||||
* @param name Movie name
|
|
||||||
* @param year Release Year
|
|
||||||
*/
|
|
||||||
public async getMovieRatings(
|
|
||||||
name: string,
|
|
||||||
year: number
|
|
||||||
): Promise<RTRating | null> {
|
|
||||||
try {
|
|
||||||
const data = await this.get<RTMultiSearchResponse>('/v2.0/search/', {
|
|
||||||
params: { q: name, limit: 10 },
|
|
||||||
});
|
|
||||||
|
|
||||||
// First, attempt to match exact name and year
|
|
||||||
let movie = data.movies.find(
|
|
||||||
(movie) => movie.year === year && movie.name === name
|
|
||||||
);
|
|
||||||
|
|
||||||
// If we don't find a movie, try to match partial name and year
|
|
||||||
if (!movie) {
|
|
||||||
movie = data.movies.find(
|
|
||||||
(movie) => movie.year === year && movie.name.includes(name)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we still dont find a movie, try to match just on year
|
|
||||||
if (!movie) {
|
|
||||||
movie = data.movies.find((movie) => movie.year === year);
|
|
||||||
}
|
|
||||||
|
|
||||||
// One last try, try exact name match only
|
|
||||||
if (!movie) {
|
|
||||||
movie = data.movies.find((movie) => movie.name === name);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!movie) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: movie.name,
|
|
||||||
url: `https://www.rottentomatoes.com${movie.url}`,
|
|
||||||
criticsRating:
|
|
||||||
movie.meterClass === 'certified_fresh'
|
|
||||||
? 'Certified Fresh'
|
|
||||||
: movie.meterClass === 'fresh'
|
|
||||||
? 'Fresh'
|
|
||||||
: 'Rotten',
|
|
||||||
criticsScore: movie.meterScore,
|
|
||||||
year: movie.year,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(
|
|
||||||
`[RT API] Failed to retrieve movie ratings: ${e.message}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getTVRatings(
|
|
||||||
name: string,
|
|
||||||
year?: number
|
|
||||||
): Promise<RTRating | null> {
|
|
||||||
try {
|
|
||||||
const data = await this.get<RTMultiSearchResponse>('/v2.0/search/', {
|
|
||||||
params: { q: name, limit: 10 },
|
|
||||||
});
|
|
||||||
|
|
||||||
let tvshow: RTTvSearchResult | undefined = data.tvSeries[0];
|
|
||||||
|
|
||||||
if (year) {
|
|
||||||
tvshow = data.tvSeries.find((series) => series.startYear === year);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tvshow) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: tvshow.title,
|
|
||||||
url: `https://www.rottentomatoes.com${tvshow.url}`,
|
|
||||||
criticsRating: tvshow.meterClass === 'fresh' ? 'Fresh' : 'Rotten',
|
|
||||||
criticsScore: tvshow.meterScore,
|
|
||||||
year: tvshow.startYear,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(`[RT API] Failed to retrieve tv ratings: ${e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default RottenTomatoes;
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { AvailableCacheIds } from '../../lib/cache';
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
import cacheManager from '../../lib/cache';
|
import type { AvailableCacheIds } from '@server/lib/cache';
|
||||||
import type { DVRSettings } from '../../lib/settings';
|
import cacheManager from '@server/lib/cache';
|
||||||
import ExternalAPI from '../externalapi';
|
import type { DVRSettings } from '@server/lib/settings';
|
||||||
|
|
||||||
export interface SystemStatus {
|
export interface SystemStatus {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -158,7 +158,12 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
|||||||
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
|
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
|
||||||
try {
|
try {
|
||||||
const response = await this.axios.get<QueueResponse<QueueItemAppendT>>(
|
const response = await this.axios.get<QueueResponse<QueueItemAppendT>>(
|
||||||
`/queue`
|
`/queue`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
includeEpisode: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return response.data.records;
|
return response.data.records;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import logger from '../../logger';
|
import logger from '@server/logger';
|
||||||
import ServarrBase from './base';
|
import ServarrBase from './base';
|
||||||
|
|
||||||
export interface RadarrMovieOptions {
|
export interface RadarrMovieOptions {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import logger from '../../logger';
|
import logger from '@server/logger';
|
||||||
import ServarrBase from './base';
|
import ServarrBase from './base';
|
||||||
|
|
||||||
interface SonarrSeason {
|
export interface SonarrSeason {
|
||||||
seasonNumber: number;
|
seasonNumber: number;
|
||||||
monitored: boolean;
|
monitored: boolean;
|
||||||
statistics?: {
|
statistics?: {
|
||||||
@@ -13,6 +13,21 @@ interface SonarrSeason {
|
|||||||
percentOfEpisodes: number;
|
percentOfEpisodes: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
interface EpisodeResult {
|
||||||
|
seriesId: number;
|
||||||
|
episodeFileId: number;
|
||||||
|
seasonNumber: number;
|
||||||
|
episodeNumber: number;
|
||||||
|
title: string;
|
||||||
|
airDate: string;
|
||||||
|
airDateUtc: string;
|
||||||
|
overview: string;
|
||||||
|
hasFile: boolean;
|
||||||
|
monitored: boolean;
|
||||||
|
absoluteEpisodeNumber: number;
|
||||||
|
unverifiedSceneNumbering: boolean;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SonarrSeries {
|
export interface SonarrSeries {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -61,6 +76,15 @@ export interface SonarrSeries {
|
|||||||
ignoreEpisodesWithoutFiles?: boolean;
|
ignoreEpisodesWithoutFiles?: boolean;
|
||||||
searchForMissingEpisodes?: boolean;
|
searchForMissingEpisodes?: boolean;
|
||||||
};
|
};
|
||||||
|
statistics: {
|
||||||
|
seasonCount: number;
|
||||||
|
episodeFileCount: number;
|
||||||
|
episodeCount: number;
|
||||||
|
totalEpisodeCount: number;
|
||||||
|
sizeOnDisk: number;
|
||||||
|
releaseGroups: string[];
|
||||||
|
percentOfEpisodes: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddSeriesOptions {
|
export interface AddSeriesOptions {
|
||||||
@@ -82,7 +106,11 @@ export interface LanguageProfile {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
|
class SonarrAPI extends ServarrBase<{
|
||||||
|
seriesId: number;
|
||||||
|
episodeId: number;
|
||||||
|
episode: EpisodeResult;
|
||||||
|
}> {
|
||||||
constructor({ url, apiKey }: { url: string; apiKey: string }) {
|
constructor({ url, apiKey }: { url: string; apiKey: string }) {
|
||||||
super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' });
|
super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' });
|
||||||
}
|
}
|
||||||
@@ -97,6 +125,16 @@ class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getSeriesById(id: number): Promise<SonarrSeries> {
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get<SonarrSeries>(`/series/${id}`);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[Sonarr] Failed to retrieve series by ID: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
|
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
|
||||||
try {
|
try {
|
||||||
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
|
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import type { User } from '@server/entity/User';
|
||||||
|
import type { TautulliSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
import type { AxiosInstance } from 'axios';
|
import type { AxiosInstance } from 'axios';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { uniqWith } from 'lodash';
|
import { uniqWith } from 'lodash';
|
||||||
import type { User } from '../entity/User';
|
|
||||||
import type { TautulliSettings } from '../lib/settings';
|
|
||||||
import logger from '../logger';
|
|
||||||
|
|
||||||
export interface TautulliHistoryRecord {
|
export interface TautulliHistoryRecord {
|
||||||
date: number;
|
date: number;
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
|
import cacheManager from '@server/lib/cache';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
import cacheManager from '../../lib/cache';
|
|
||||||
import ExternalAPI from '../externalapi';
|
|
||||||
import type {
|
import type {
|
||||||
TmdbCollection,
|
TmdbCollection,
|
||||||
|
TmdbCompanySearchResponse,
|
||||||
TmdbExternalIdResponse,
|
TmdbExternalIdResponse,
|
||||||
TmdbGenre,
|
TmdbGenre,
|
||||||
TmdbGenresResult,
|
TmdbGenresResult,
|
||||||
|
TmdbKeyword,
|
||||||
|
TmdbKeywordSearchResponse,
|
||||||
TmdbLanguage,
|
TmdbLanguage,
|
||||||
TmdbMovieDetails,
|
TmdbMovieDetails,
|
||||||
TmdbNetwork,
|
TmdbNetwork,
|
||||||
@@ -19,6 +22,8 @@ import type {
|
|||||||
TmdbSeasonWithEpisodes,
|
TmdbSeasonWithEpisodes,
|
||||||
TmdbTvDetails,
|
TmdbTvDetails,
|
||||||
TmdbUpcomingMoviesResponse,
|
TmdbUpcomingMoviesResponse,
|
||||||
|
TmdbWatchProviderDetails,
|
||||||
|
TmdbWatchProviderRegion,
|
||||||
} from './interfaces';
|
} from './interfaces';
|
||||||
|
|
||||||
interface SearchOptions {
|
interface SearchOptions {
|
||||||
@@ -32,30 +37,43 @@ interface SingleSearchOptions extends SearchOptions {
|
|||||||
year?: number;
|
year?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SortOptions =
|
||||||
|
| 'popularity.asc'
|
||||||
|
| 'popularity.desc'
|
||||||
|
| 'release_date.asc'
|
||||||
|
| 'release_date.desc'
|
||||||
|
| 'revenue.asc'
|
||||||
|
| 'revenue.desc'
|
||||||
|
| 'primary_release_date.asc'
|
||||||
|
| 'primary_release_date.desc'
|
||||||
|
| 'original_title.asc'
|
||||||
|
| 'original_title.desc'
|
||||||
|
| 'vote_average.asc'
|
||||||
|
| 'vote_average.desc'
|
||||||
|
| 'vote_count.asc'
|
||||||
|
| 'vote_count.desc'
|
||||||
|
| 'first_air_date.asc'
|
||||||
|
| 'first_air_date.desc';
|
||||||
|
|
||||||
interface DiscoverMovieOptions {
|
interface DiscoverMovieOptions {
|
||||||
page?: number;
|
page?: number;
|
||||||
includeAdult?: boolean;
|
includeAdult?: boolean;
|
||||||
language?: string;
|
language?: string;
|
||||||
primaryReleaseDateGte?: string;
|
primaryReleaseDateGte?: string;
|
||||||
primaryReleaseDateLte?: string;
|
primaryReleaseDateLte?: string;
|
||||||
|
withRuntimeGte?: string;
|
||||||
|
withRuntimeLte?: string;
|
||||||
|
voteAverageGte?: string;
|
||||||
|
voteAverageLte?: string;
|
||||||
|
voteCountGte?: string;
|
||||||
|
voteCountLte?: string;
|
||||||
originalLanguage?: string;
|
originalLanguage?: string;
|
||||||
genre?: number;
|
genre?: string;
|
||||||
studio?: number;
|
studio?: string;
|
||||||
sortBy?:
|
keywords?: string;
|
||||||
| 'popularity.asc'
|
sortBy?: SortOptions;
|
||||||
| 'popularity.desc'
|
watchRegion?: string;
|
||||||
| 'release_date.asc'
|
watchProviders?: string;
|
||||||
| 'release_date.desc'
|
|
||||||
| 'revenue.asc'
|
|
||||||
| 'revenue.desc'
|
|
||||||
| 'primary_release_date.asc'
|
|
||||||
| 'primary_release_date.desc'
|
|
||||||
| 'original_title.asc'
|
|
||||||
| 'original_title.desc'
|
|
||||||
| 'vote_average.asc'
|
|
||||||
| 'vote_average.desc'
|
|
||||||
| 'vote_count.asc'
|
|
||||||
| 'vote_count.desc';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DiscoverTvOptions {
|
interface DiscoverTvOptions {
|
||||||
@@ -63,19 +81,20 @@ interface DiscoverTvOptions {
|
|||||||
language?: string;
|
language?: string;
|
||||||
firstAirDateGte?: string;
|
firstAirDateGte?: string;
|
||||||
firstAirDateLte?: string;
|
firstAirDateLte?: string;
|
||||||
|
withRuntimeGte?: string;
|
||||||
|
withRuntimeLte?: string;
|
||||||
|
voteAverageGte?: string;
|
||||||
|
voteAverageLte?: string;
|
||||||
|
voteCountGte?: string;
|
||||||
|
voteCountLte?: string;
|
||||||
includeEmptyReleaseDate?: boolean;
|
includeEmptyReleaseDate?: boolean;
|
||||||
originalLanguage?: string;
|
originalLanguage?: string;
|
||||||
genre?: number;
|
genre?: string;
|
||||||
network?: number;
|
network?: number;
|
||||||
sortBy?:
|
keywords?: string;
|
||||||
| 'popularity.asc'
|
sortBy?: SortOptions;
|
||||||
| 'popularity.desc'
|
watchRegion?: string;
|
||||||
| 'vote_average.asc'
|
watchProviders?: string;
|
||||||
| 'vote_average.desc'
|
|
||||||
| 'vote_count.asc'
|
|
||||||
| 'vote_count.desc'
|
|
||||||
| 'first_air_date.asc'
|
|
||||||
| 'first_air_date.desc';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class TheMovieDb extends ExternalAPI {
|
class TheMovieDb extends ExternalAPI {
|
||||||
@@ -94,7 +113,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
nodeCache: cacheManager.getCache('tmdb').data,
|
nodeCache: cacheManager.getCache('tmdb').data,
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
maxRequests: 20,
|
maxRequests: 20,
|
||||||
maxRPS: 1,
|
maxRPS: 50,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -237,7 +256,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
params: {
|
params: {
|
||||||
language,
|
language,
|
||||||
append_to_response:
|
append_to_response:
|
||||||
'credits,external_ids,videos,release_dates,watch/providers',
|
'credits,external_ids,videos,keywords,release_dates,watch/providers',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
43200
|
43200
|
||||||
@@ -440,8 +459,27 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
originalLanguage,
|
originalLanguage,
|
||||||
genre,
|
genre,
|
||||||
studio,
|
studio,
|
||||||
|
keywords,
|
||||||
|
withRuntimeGte,
|
||||||
|
withRuntimeLte,
|
||||||
|
voteAverageGte,
|
||||||
|
voteAverageLte,
|
||||||
|
voteCountGte,
|
||||||
|
voteCountLte,
|
||||||
|
watchProviders,
|
||||||
|
watchRegion,
|
||||||
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
||||||
try {
|
try {
|
||||||
|
const defaultFutureDate = new Date(
|
||||||
|
Date.now() + 1000 * 60 * 60 * 24 * (365 * 1.5)
|
||||||
|
)
|
||||||
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
|
||||||
|
const defaultPastDate = new Date('1900-01-01')
|
||||||
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
|
||||||
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
|
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
|
||||||
params: {
|
params: {
|
||||||
sort_by: sortBy,
|
sort_by: sortBy,
|
||||||
@@ -449,11 +487,33 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
include_adult: includeAdult,
|
include_adult: includeAdult,
|
||||||
language,
|
language,
|
||||||
region: this.region,
|
region: this.region,
|
||||||
with_original_language: originalLanguage ?? this.originalLanguage,
|
with_original_language:
|
||||||
'primary_release_date.gte': primaryReleaseDateGte,
|
originalLanguage && originalLanguage !== 'all'
|
||||||
'primary_release_date.lte': primaryReleaseDateLte,
|
? originalLanguage
|
||||||
|
: originalLanguage === 'all'
|
||||||
|
? undefined
|
||||||
|
: this.originalLanguage,
|
||||||
|
// Set our release date values, but check if one is set and not the other,
|
||||||
|
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
||||||
|
'primary_release_date.gte':
|
||||||
|
!primaryReleaseDateGte && primaryReleaseDateLte
|
||||||
|
? defaultPastDate
|
||||||
|
: primaryReleaseDateGte,
|
||||||
|
'primary_release_date.lte':
|
||||||
|
!primaryReleaseDateLte && primaryReleaseDateGte
|
||||||
|
? defaultFutureDate
|
||||||
|
: primaryReleaseDateLte,
|
||||||
with_genres: genre,
|
with_genres: genre,
|
||||||
with_companies: studio,
|
with_companies: studio,
|
||||||
|
with_keywords: keywords,
|
||||||
|
'with_runtime.gte': withRuntimeGte,
|
||||||
|
'with_runtime.lte': withRuntimeLte,
|
||||||
|
'vote_average.gte': voteAverageGte,
|
||||||
|
'vote_average.lte': voteAverageLte,
|
||||||
|
'vote_count.gte': voteCountGte,
|
||||||
|
'vote_count.lte': voteCountLte,
|
||||||
|
watch_region: watchRegion,
|
||||||
|
with_watch_providers: watchProviders,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -473,20 +533,61 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
originalLanguage,
|
originalLanguage,
|
||||||
genre,
|
genre,
|
||||||
network,
|
network,
|
||||||
|
keywords,
|
||||||
|
withRuntimeGte,
|
||||||
|
withRuntimeLte,
|
||||||
|
voteAverageGte,
|
||||||
|
voteAverageLte,
|
||||||
|
voteCountGte,
|
||||||
|
voteCountLte,
|
||||||
|
watchProviders,
|
||||||
|
watchRegion,
|
||||||
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
||||||
try {
|
try {
|
||||||
|
const defaultFutureDate = new Date(
|
||||||
|
Date.now() + 1000 * 60 * 60 * 24 * (365 * 1.5)
|
||||||
|
)
|
||||||
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
|
||||||
|
const defaultPastDate = new Date('1900-01-01')
|
||||||
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
|
||||||
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
|
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
|
||||||
params: {
|
params: {
|
||||||
sort_by: sortBy,
|
sort_by: sortBy,
|
||||||
page,
|
page,
|
||||||
language,
|
language,
|
||||||
region: this.region,
|
region: this.region,
|
||||||
'first_air_date.gte': firstAirDateGte,
|
// Set our release date values, but check if one is set and not the other,
|
||||||
'first_air_date.lte': firstAirDateLte,
|
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
||||||
with_original_language: originalLanguage ?? this.originalLanguage,
|
'first_air_date.gte':
|
||||||
|
!firstAirDateGte && firstAirDateLte
|
||||||
|
? defaultPastDate
|
||||||
|
: firstAirDateGte,
|
||||||
|
'first_air_date.lte':
|
||||||
|
!firstAirDateLte && firstAirDateGte
|
||||||
|
? defaultFutureDate
|
||||||
|
: firstAirDateLte,
|
||||||
|
with_original_language:
|
||||||
|
originalLanguage && originalLanguage !== 'all'
|
||||||
|
? originalLanguage
|
||||||
|
: originalLanguage === 'all'
|
||||||
|
? undefined
|
||||||
|
: this.originalLanguage,
|
||||||
include_null_first_air_dates: includeEmptyReleaseDate,
|
include_null_first_air_dates: includeEmptyReleaseDate,
|
||||||
with_genres: genre,
|
with_genres: genre,
|
||||||
with_networks: network,
|
with_networks: network,
|
||||||
|
with_keywords: keywords,
|
||||||
|
'with_runtime.gte': withRuntimeGte,
|
||||||
|
'with_runtime.lte': withRuntimeLte,
|
||||||
|
'vote_average.gte': voteAverageGte,
|
||||||
|
'vote_average.lte': voteAverageLte,
|
||||||
|
'vote_count.gte': voteCountGte,
|
||||||
|
'vote_count.lte': voteCountLte,
|
||||||
|
with_watch_providers: watchProviders,
|
||||||
|
watch_region: watchRegion,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -874,6 +975,152 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
throw new Error(`[TMDB] Failed to fetch TV genres: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch TV genres: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getKeywordDetails({
|
||||||
|
keywordId,
|
||||||
|
}: {
|
||||||
|
keywordId: number;
|
||||||
|
}): Promise<TmdbKeyword> {
|
||||||
|
try {
|
||||||
|
const data = await this.get<TmdbKeyword>(
|
||||||
|
`/keyword/${keywordId}`,
|
||||||
|
undefined,
|
||||||
|
604800 // 7 days
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[TMDB] Failed to fetch keyword: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async searchKeyword({
|
||||||
|
query,
|
||||||
|
page = 1,
|
||||||
|
}: {
|
||||||
|
query: string;
|
||||||
|
page?: number;
|
||||||
|
}): Promise<TmdbKeywordSearchResponse> {
|
||||||
|
try {
|
||||||
|
const data = await this.get<TmdbKeywordSearchResponse>(
|
||||||
|
'/search/keyword',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
query,
|
||||||
|
page,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
86400 // 24 hours
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[TMDB] Failed to search keyword: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async searchCompany({
|
||||||
|
query,
|
||||||
|
page = 1,
|
||||||
|
}: {
|
||||||
|
query: string;
|
||||||
|
page?: number;
|
||||||
|
}): Promise<TmdbCompanySearchResponse> {
|
||||||
|
try {
|
||||||
|
const data = await this.get<TmdbCompanySearchResponse>(
|
||||||
|
'/search/company',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
query,
|
||||||
|
page,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
86400 // 24 hours
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[TMDB] Failed to search companies: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAvailableWatchProviderRegions({
|
||||||
|
language,
|
||||||
|
}: {
|
||||||
|
language?: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const data = await this.get<{ results: TmdbWatchProviderRegion[] }>(
|
||||||
|
'/watch/providers/regions',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
language: language ?? this.originalLanguage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
86400 // 24 hours
|
||||||
|
);
|
||||||
|
|
||||||
|
return data.results;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`[TMDB] Failed to fetch available watch regions: ${e.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getMovieWatchProviders({
|
||||||
|
language,
|
||||||
|
watchRegion,
|
||||||
|
}: {
|
||||||
|
language?: string;
|
||||||
|
watchRegion: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
||||||
|
'/watch/providers/movie',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
language: language ?? this.originalLanguage,
|
||||||
|
watch_region: watchRegion,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
86400 // 24 hours
|
||||||
|
);
|
||||||
|
|
||||||
|
return data.results;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`[TMDB] Failed to fetch movie watch providers: ${e.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTvWatchProviders({
|
||||||
|
language,
|
||||||
|
watchRegion,
|
||||||
|
}: {
|
||||||
|
language?: string;
|
||||||
|
watchRegion: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
||||||
|
'/watch/providers/tv',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
language: language ?? this.originalLanguage,
|
||||||
|
watch_region: watchRegion,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
86400 // 24 hours
|
||||||
|
);
|
||||||
|
|
||||||
|
return data.results;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`[TMDB] Failed to fetch TV watch providers: ${e.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TheMovieDb;
|
export default TheMovieDb;
|
||||||
|
|||||||
@@ -28,6 +28,18 @@ export interface TmdbTvResult extends TmdbMediaResult {
|
|||||||
first_air_date: string;
|
first_air_date: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TmdbCollectionResult {
|
||||||
|
id: number;
|
||||||
|
media_type: 'collection';
|
||||||
|
title: string;
|
||||||
|
original_title: string;
|
||||||
|
adult: boolean;
|
||||||
|
poster_path?: string;
|
||||||
|
backdrop_path?: string;
|
||||||
|
overview: string;
|
||||||
|
original_language: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TmdbPersonResult {
|
export interface TmdbPersonResult {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -45,7 +57,12 @@ interface TmdbPaginatedResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse {
|
export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse {
|
||||||
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[];
|
results: (
|
||||||
|
| TmdbMovieResult
|
||||||
|
| TmdbTvResult
|
||||||
|
| TmdbPersonResult
|
||||||
|
| TmdbCollectionResult
|
||||||
|
)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse {
|
export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse {
|
||||||
@@ -171,6 +188,9 @@ export interface TmdbMovieDetails {
|
|||||||
id: number;
|
id: number;
|
||||||
results?: { [iso_3166_1: string]: TmdbWatchProviders };
|
results?: { [iso_3166_1: string]: TmdbWatchProviders };
|
||||||
};
|
};
|
||||||
|
keywords: {
|
||||||
|
keywords: TmdbKeyword[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TmdbVideo {
|
export interface TmdbVideo {
|
||||||
@@ -191,7 +211,7 @@ export interface TmdbVideo {
|
|||||||
|
|
||||||
export interface TmdbTvEpisodeResult {
|
export interface TmdbTvEpisodeResult {
|
||||||
id: number;
|
id: number;
|
||||||
air_date: string;
|
air_date: string | null;
|
||||||
episode_number: number;
|
episode_number: number;
|
||||||
name: string;
|
name: string;
|
||||||
overview: string;
|
overview: string;
|
||||||
@@ -372,7 +392,8 @@ export interface TmdbPersonCombinedCredits {
|
|||||||
crew: TmdbPersonCreditCrew[];
|
crew: TmdbPersonCreditCrew[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult {
|
export interface TmdbSeasonWithEpisodes
|
||||||
|
extends Omit<TmdbTvSeasonResult, 'episode_count'> {
|
||||||
episodes: TmdbTvEpisodeResult[];
|
episodes: TmdbTvEpisodeResult[];
|
||||||
external_ids: TmdbExternalIds;
|
external_ids: TmdbExternalIds;
|
||||||
}
|
}
|
||||||
@@ -427,3 +448,24 @@ export interface TmdbWatchProviderDetails {
|
|||||||
provider_id: number;
|
provider_id: number;
|
||||||
provider_name: string;
|
provider_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TmdbKeywordSearchResponse extends TmdbPaginatedResponse {
|
||||||
|
results: TmdbKeyword[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// We have production companies, but the company search results return less data
|
||||||
|
export interface TmdbCompany {
|
||||||
|
id: number;
|
||||||
|
logo_path?: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TmdbCompanySearchResponse extends TmdbPaginatedResponse {
|
||||||
|
results: TmdbCompany[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TmdbWatchProviderRegion {
|
||||||
|
iso_3166_1: string;
|
||||||
|
english_name: string;
|
||||||
|
native_name: string;
|
||||||
|
}
|
||||||
|
|||||||
100
server/constants/discover.ts
Normal file
100
server/constants/discover.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||||
|
|
||||||
|
export enum DiscoverSliderType {
|
||||||
|
RECENTLY_ADDED = 1,
|
||||||
|
RECENT_REQUESTS,
|
||||||
|
PLEX_WATCHLIST,
|
||||||
|
TRENDING,
|
||||||
|
POPULAR_MOVIES,
|
||||||
|
MOVIE_GENRES,
|
||||||
|
UPCOMING_MOVIES,
|
||||||
|
STUDIOS,
|
||||||
|
POPULAR_TV,
|
||||||
|
TV_GENRES,
|
||||||
|
UPCOMING_TV,
|
||||||
|
NETWORKS,
|
||||||
|
TMDB_MOVIE_KEYWORD,
|
||||||
|
TMDB_MOVIE_GENRE,
|
||||||
|
TMDB_TV_KEYWORD,
|
||||||
|
TMDB_TV_GENRE,
|
||||||
|
TMDB_SEARCH,
|
||||||
|
TMDB_STUDIO,
|
||||||
|
TMDB_NETWORK,
|
||||||
|
TMDB_MOVIE_STREAMING_SERVICES,
|
||||||
|
TMDB_TV_STREAMING_SERVICES,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultSliders: Partial<DiscoverSlider>[] = [
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.RECENTLY_ADDED,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.RECENT_REQUESTS,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.PLEX_WATCHLIST,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TRENDING,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.POPULAR_MOVIES,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.MOVIE_GENRES,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.UPCOMING_MOVIES,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.STUDIOS,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.POPULAR_TV,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TV_GENRES,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.UPCOMING_TV,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.NETWORKS,
|
||||||
|
enabled: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
order: 11,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -34,7 +34,7 @@ const dataSource = new DataSource(
|
|||||||
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig
|
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getRepository = <Entity>(
|
export const getRepository = <Entity extends object>(
|
||||||
target: EntityTarget<Entity>
|
target: EntityTarget<Entity>
|
||||||
): Repository<Entity> => {
|
): Repository<Entity> => {
|
||||||
return dataSource.getRepository(target);
|
return dataSource.getRepository(target);
|
||||||
|
|||||||
69
server/entity/DiscoverSlider.ts
Normal file
69
server/entity/DiscoverSlider.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { DiscoverSliderType } from '@server/constants/discover';
|
||||||
|
import { defaultSliders } from '@server/constants/discover';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
class DiscoverSlider {
|
||||||
|
public static async bootstrapSliders(): Promise<void> {
|
||||||
|
const sliderRepository = getRepository(DiscoverSlider);
|
||||||
|
|
||||||
|
for (const slider of defaultSliders) {
|
||||||
|
const existingSlider = await sliderRepository.findOne({
|
||||||
|
where: {
|
||||||
|
type: slider.type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingSlider) {
|
||||||
|
logger.info('Creating built-in discovery slider', {
|
||||||
|
label: 'Discover Slider',
|
||||||
|
slider,
|
||||||
|
});
|
||||||
|
await sliderRepository.save(new DiscoverSlider(slider));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
public id: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int' })
|
||||||
|
public type: DiscoverSliderType;
|
||||||
|
|
||||||
|
@Column({ type: 'int' })
|
||||||
|
public order: number;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
public isBuiltIn: boolean;
|
||||||
|
|
||||||
|
@Column({ default: true })
|
||||||
|
public enabled: boolean;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
// Title is not required for built in sliders because we will
|
||||||
|
// use translations for them.
|
||||||
|
public title?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public data?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
public createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
public updatedAt: Date;
|
||||||
|
|
||||||
|
constructor(init?: Partial<DiscoverSlider>) {
|
||||||
|
Object.assign(this, init);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiscoverSlider;
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { IssueType } from '@server/constants/issue';
|
||||||
|
import { IssueStatus } from '@server/constants/issue';
|
||||||
import {
|
import {
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
@@ -7,8 +9,6 @@ import {
|
|||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import type { IssueType } from '../constants/issue';
|
|
||||||
import { IssueStatus } from '../constants/issue';
|
|
||||||
import IssueComment from './IssueComment';
|
import IssueComment from './IssueComment';
|
||||||
import Media from './Media';
|
import Media from './Media';
|
||||||
import { User } from './User';
|
import { User } from './User';
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
import RadarrAPI from '@server/api/servarr/radarr';
|
||||||
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
|
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
||||||
|
import downloadTracker from '@server/lib/downloadtracker';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
import {
|
import {
|
||||||
AfterLoad,
|
AfterLoad,
|
||||||
Column,
|
Column,
|
||||||
@@ -9,14 +17,6 @@ import {
|
|||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import RadarrAPI from '../api/servarr/radarr';
|
|
||||||
import SonarrAPI from '../api/servarr/sonarr';
|
|
||||||
import { MediaStatus, MediaType } from '../constants/media';
|
|
||||||
import type { DownloadingItem } from '../lib/downloadtracker';
|
|
||||||
import downloadTracker from '../lib/downloadtracker';
|
|
||||||
import { getRepository } from '../datasource';
|
|
||||||
import { getSettings } from '../lib/settings';
|
|
||||||
import logger from '../logger';
|
|
||||||
import Issue from './Issue';
|
import Issue from './Issue';
|
||||||
import { MediaRequest } from './MediaRequest';
|
import { MediaRequest } from './MediaRequest';
|
||||||
import Season from './Season';
|
import Season from './Season';
|
||||||
@@ -114,29 +114,29 @@ class Media {
|
|||||||
@Column({ type: 'datetime', nullable: true })
|
@Column({ type: 'datetime', nullable: true })
|
||||||
public mediaAddedAt: Date;
|
public mediaAddedAt: Date;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'int' })
|
||||||
public serviceId?: number;
|
public serviceId?: number | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'int' })
|
||||||
public serviceId4k?: number;
|
public serviceId4k?: number | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'int' })
|
||||||
public externalServiceId?: number;
|
public externalServiceId?: number | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'int' })
|
||||||
public externalServiceId4k?: number;
|
public externalServiceId4k?: number | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
public externalServiceSlug?: string;
|
public externalServiceSlug?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
public externalServiceSlug4k?: string;
|
public externalServiceSlug4k?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
public ratingKey?: string;
|
public ratingKey?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
public ratingKey4k?: string;
|
public ratingKey4k?: string | null;
|
||||||
|
|
||||||
public serviceUrl?: string;
|
public serviceUrl?: string;
|
||||||
public serviceUrl4k?: string;
|
public serviceUrl4k?: string;
|
||||||
@@ -260,7 +260,9 @@ class Media {
|
|||||||
if (this.mediaType === MediaType.MOVIE) {
|
if (this.mediaType === MediaType.MOVIE) {
|
||||||
if (
|
if (
|
||||||
this.externalServiceId !== undefined &&
|
this.externalServiceId !== undefined &&
|
||||||
this.serviceId !== undefined
|
this.externalServiceId !== null &&
|
||||||
|
this.serviceId !== undefined &&
|
||||||
|
this.serviceId !== null
|
||||||
) {
|
) {
|
||||||
this.downloadStatus = downloadTracker.getMovieProgress(
|
this.downloadStatus = downloadTracker.getMovieProgress(
|
||||||
this.serviceId,
|
this.serviceId,
|
||||||
@@ -270,7 +272,9 @@ class Media {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
this.externalServiceId4k !== undefined &&
|
this.externalServiceId4k !== undefined &&
|
||||||
this.serviceId4k !== undefined
|
this.externalServiceId4k !== null &&
|
||||||
|
this.serviceId4k !== undefined &&
|
||||||
|
this.serviceId4k !== null
|
||||||
) {
|
) {
|
||||||
this.downloadStatus4k = downloadTracker.getMovieProgress(
|
this.downloadStatus4k = downloadTracker.getMovieProgress(
|
||||||
this.serviceId4k,
|
this.serviceId4k,
|
||||||
@@ -282,7 +286,9 @@ class Media {
|
|||||||
if (this.mediaType === MediaType.TV) {
|
if (this.mediaType === MediaType.TV) {
|
||||||
if (
|
if (
|
||||||
this.externalServiceId !== undefined &&
|
this.externalServiceId !== undefined &&
|
||||||
this.serviceId !== undefined
|
this.externalServiceId !== null &&
|
||||||
|
this.serviceId !== undefined &&
|
||||||
|
this.serviceId !== null
|
||||||
) {
|
) {
|
||||||
this.downloadStatus = downloadTracker.getSeriesProgress(
|
this.downloadStatus = downloadTracker.getSeriesProgress(
|
||||||
this.serviceId,
|
this.serviceId,
|
||||||
@@ -292,7 +298,9 @@ class Media {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
this.externalServiceId4k !== undefined &&
|
this.externalServiceId4k !== undefined &&
|
||||||
this.serviceId4k !== undefined
|
this.externalServiceId4k !== null &&
|
||||||
|
this.serviceId4k !== undefined &&
|
||||||
|
this.serviceId4k !== null
|
||||||
) {
|
) {
|
||||||
this.downloadStatus4k = downloadTracker.getSeriesProgress(
|
this.downloadStatus4k = downloadTracker.getSeriesProgress(
|
||||||
this.serviceId4k,
|
this.serviceId4k,
|
||||||
|
|||||||
@@ -1,3 +1,23 @@
|
|||||||
|
import type { RadarrMovieOptions } from '@server/api/servarr/radarr';
|
||||||
|
import RadarrAPI from '@server/api/servarr/radarr';
|
||||||
|
import type {
|
||||||
|
AddSeriesOptions,
|
||||||
|
SonarrSeries,
|
||||||
|
} from '@server/api/servarr/sonarr';
|
||||||
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
|
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||||
|
import {
|
||||||
|
MediaRequestStatus,
|
||||||
|
MediaStatus,
|
||||||
|
MediaType,
|
||||||
|
} from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
|
||||||
|
import notificationManager, { Notification } from '@server/lib/notifications';
|
||||||
|
import { Permission } from '@server/lib/permissions';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
import { isEqual, truncate } from 'lodash';
|
import { isEqual, truncate } from 'lodash';
|
||||||
import {
|
import {
|
||||||
AfterInsert,
|
AfterInsert,
|
||||||
@@ -12,23 +32,339 @@ import {
|
|||||||
RelationCount,
|
RelationCount,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import type { RadarrMovieOptions } from '../api/servarr/radarr';
|
|
||||||
import RadarrAPI from '../api/servarr/radarr';
|
|
||||||
import type { AddSeriesOptions, SonarrSeries } from '../api/servarr/sonarr';
|
|
||||||
import SonarrAPI from '../api/servarr/sonarr';
|
|
||||||
import TheMovieDb from '../api/themoviedb';
|
|
||||||
import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants';
|
|
||||||
import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
|
|
||||||
import { getRepository } from '../datasource';
|
|
||||||
import notificationManager, { Notification } from '../lib/notifications';
|
|
||||||
import { getSettings } from '../lib/settings';
|
|
||||||
import logger from '../logger';
|
|
||||||
import Media from './Media';
|
import Media from './Media';
|
||||||
import SeasonRequest from './SeasonRequest';
|
import SeasonRequest from './SeasonRequest';
|
||||||
import { User } from './User';
|
import { User } from './User';
|
||||||
|
|
||||||
|
export class RequestPermissionError extends Error {}
|
||||||
|
export class QuotaRestrictedError extends Error {}
|
||||||
|
export class DuplicateMediaRequestError extends Error {}
|
||||||
|
export class NoSeasonsAvailableError extends Error {}
|
||||||
|
|
||||||
|
type MediaRequestOptions = {
|
||||||
|
isAutoRequest?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class MediaRequest {
|
export class MediaRequest {
|
||||||
|
public static async request(
|
||||||
|
requestBody: MediaRequestBody,
|
||||||
|
user: User,
|
||||||
|
options: MediaRequestOptions = {}
|
||||||
|
): Promise<MediaRequest> {
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
const userRepository = getRepository(User);
|
||||||
|
|
||||||
|
let requestUser = user;
|
||||||
|
|
||||||
|
if (
|
||||||
|
requestBody.userId &&
|
||||||
|
!requestUser.hasPermission([
|
||||||
|
Permission.MANAGE_USERS,
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
throw new RequestPermissionError(
|
||||||
|
'You do not have permission to modify the request user.'
|
||||||
|
);
|
||||||
|
} else if (requestBody.userId) {
|
||||||
|
requestUser = await userRepository.findOneOrFail({
|
||||||
|
where: { id: requestBody.userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requestUser) {
|
||||||
|
throw new Error('User missing from request context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
requestBody.mediaType === MediaType.MOVIE &&
|
||||||
|
!requestUser.hasPermission(
|
||||||
|
requestBody.is4k
|
||||||
|
? [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE]
|
||||||
|
: [Permission.REQUEST, Permission.REQUEST_MOVIE],
|
||||||
|
{
|
||||||
|
type: 'or',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new RequestPermissionError(
|
||||||
|
`You do not have permission to make ${
|
||||||
|
requestBody.is4k ? '4K ' : ''
|
||||||
|
}movie requests.`
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
requestBody.mediaType === MediaType.TV &&
|
||||||
|
!requestUser.hasPermission(
|
||||||
|
requestBody.is4k
|
||||||
|
? [Permission.REQUEST_4K, Permission.REQUEST_4K_TV]
|
||||||
|
: [Permission.REQUEST, Permission.REQUEST_TV],
|
||||||
|
{
|
||||||
|
type: 'or',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new RequestPermissionError(
|
||||||
|
`You do not have permission to make ${
|
||||||
|
requestBody.is4k ? '4K ' : ''
|
||||||
|
}series requests.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const quotas = await requestUser.getQuota();
|
||||||
|
|
||||||
|
if (requestBody.mediaType === MediaType.MOVIE && quotas.movie.restricted) {
|
||||||
|
throw new QuotaRestrictedError('Movie Quota exceeded.');
|
||||||
|
} else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) {
|
||||||
|
throw new QuotaRestrictedError('Series Quota exceeded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmdbMedia =
|
||||||
|
requestBody.mediaType === MediaType.MOVIE
|
||||||
|
? await tmdb.getMovie({ movieId: requestBody.mediaId })
|
||||||
|
: await tmdb.getTvShow({ tvId: requestBody.mediaId });
|
||||||
|
|
||||||
|
let media = await mediaRepository.findOne({
|
||||||
|
where: {
|
||||||
|
tmdbId: requestBody.mediaId,
|
||||||
|
mediaType: requestBody.mediaType,
|
||||||
|
},
|
||||||
|
relations: ['requests'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!media) {
|
||||||
|
media = new Media({
|
||||||
|
tmdbId: tmdbMedia.id,
|
||||||
|
tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id,
|
||||||
|
status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
|
||||||
|
status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
|
||||||
|
mediaType: requestBody.mediaType,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
|
||||||
|
media.status = MediaStatus.PENDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) {
|
||||||
|
media.status4k = MediaStatus.PENDING;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await requestRepository
|
||||||
|
.createQueryBuilder('request')
|
||||||
|
.leftJoin('request.media', 'media')
|
||||||
|
.leftJoinAndSelect('request.requestedBy', 'user')
|
||||||
|
.where('request.is4k = :is4k', { is4k: requestBody.is4k })
|
||||||
|
.andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id })
|
||||||
|
.andWhere('media.mediaType = :mediaType', {
|
||||||
|
mediaType: requestBody.mediaType,
|
||||||
|
})
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
if (existing && existing.length > 0) {
|
||||||
|
// If there is an existing movie request that isn't declined, don't allow a new one.
|
||||||
|
if (
|
||||||
|
requestBody.mediaType === MediaType.MOVIE &&
|
||||||
|
existing[0].status !== MediaRequestStatus.DECLINED
|
||||||
|
) {
|
||||||
|
logger.warn('Duplicate request for media blocked', {
|
||||||
|
tmdbId: tmdbMedia.id,
|
||||||
|
mediaType: requestBody.mediaType,
|
||||||
|
is4k: requestBody.is4k,
|
||||||
|
label: 'Media Request',
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new DuplicateMediaRequestError(
|
||||||
|
'Request for this media already exists.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If an existing auto-request for this media exists from the same user,
|
||||||
|
// don't allow a new one.
|
||||||
|
if (
|
||||||
|
existing.find(
|
||||||
|
(r) => r.requestedBy.id === requestUser.id && r.isAutoRequest
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new DuplicateMediaRequestError(
|
||||||
|
'Auto-request for this media and user already exists.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestBody.mediaType === MediaType.MOVIE) {
|
||||||
|
await mediaRepository.save(media);
|
||||||
|
|
||||||
|
const request = new MediaRequest({
|
||||||
|
type: MediaType.MOVIE,
|
||||||
|
media,
|
||||||
|
requestedBy: requestUser,
|
||||||
|
// If the user is an admin or has the "auto approve" permission, automatically approve the request
|
||||||
|
status: user.hasPermission(
|
||||||
|
[
|
||||||
|
requestBody.is4k
|
||||||
|
? Permission.AUTO_APPROVE_4K
|
||||||
|
: Permission.AUTO_APPROVE,
|
||||||
|
requestBody.is4k
|
||||||
|
? Permission.AUTO_APPROVE_4K_MOVIE
|
||||||
|
: Permission.AUTO_APPROVE_MOVIE,
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
],
|
||||||
|
{ type: 'or' }
|
||||||
|
)
|
||||||
|
? MediaRequestStatus.APPROVED
|
||||||
|
: MediaRequestStatus.PENDING,
|
||||||
|
modifiedBy: user.hasPermission(
|
||||||
|
[
|
||||||
|
requestBody.is4k
|
||||||
|
? Permission.AUTO_APPROVE_4K
|
||||||
|
: Permission.AUTO_APPROVE,
|
||||||
|
requestBody.is4k
|
||||||
|
? Permission.AUTO_APPROVE_4K_MOVIE
|
||||||
|
: Permission.AUTO_APPROVE_MOVIE,
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
],
|
||||||
|
{ type: 'or' }
|
||||||
|
)
|
||||||
|
? user
|
||||||
|
: undefined,
|
||||||
|
is4k: requestBody.is4k,
|
||||||
|
serverId: requestBody.serverId,
|
||||||
|
profileId: requestBody.profileId,
|
||||||
|
rootFolder: requestBody.rootFolder,
|
||||||
|
tags: requestBody.tags,
|
||||||
|
isAutoRequest: options.isAutoRequest ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await requestRepository.save(request);
|
||||||
|
return request;
|
||||||
|
} else {
|
||||||
|
const tmdbMediaShow = tmdbMedia as Awaited<
|
||||||
|
ReturnType<typeof tmdb.getTvShow>
|
||||||
|
>;
|
||||||
|
const requestedSeasons =
|
||||||
|
requestBody.seasons === 'all'
|
||||||
|
? tmdbMediaShow.seasons.map((season) => season.season_number)
|
||||||
|
: (requestBody.seasons as number[]);
|
||||||
|
let existingSeasons: number[] = [];
|
||||||
|
|
||||||
|
// We need to check existing requests on this title to make sure we don't double up on seasons that were
|
||||||
|
// already requested. In the case they were, we just throw out any duplicates but still approve the request.
|
||||||
|
// (Unless there are no seasons, in which case we abort)
|
||||||
|
if (media.requests) {
|
||||||
|
existingSeasons = media.requests
|
||||||
|
.filter(
|
||||||
|
(request) =>
|
||||||
|
request.is4k === requestBody.is4k &&
|
||||||
|
request.status !== MediaRequestStatus.DECLINED
|
||||||
|
)
|
||||||
|
.reduce((seasons, request) => {
|
||||||
|
const combinedSeasons = request.seasons.map(
|
||||||
|
(season) => season.seasonNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
return [...seasons, ...combinedSeasons];
|
||||||
|
}, [] as number[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We should also check seasons that are available/partially available but don't have existing requests
|
||||||
|
if (media.seasons) {
|
||||||
|
existingSeasons = [
|
||||||
|
...existingSeasons,
|
||||||
|
...media.seasons
|
||||||
|
.filter(
|
||||||
|
(season) =>
|
||||||
|
season[requestBody.is4k ? 'status4k' : 'status'] !==
|
||||||
|
MediaStatus.UNKNOWN
|
||||||
|
)
|
||||||
|
.map((season) => season.seasonNumber),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalSeasons = requestedSeasons.filter(
|
||||||
|
(rs) => !existingSeasons.includes(rs)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (finalSeasons.length === 0) {
|
||||||
|
throw new NoSeasonsAvailableError('No seasons available to request');
|
||||||
|
} else if (
|
||||||
|
quotas.tv.limit &&
|
||||||
|
finalSeasons.length > (quotas.tv.remaining ?? 0)
|
||||||
|
) {
|
||||||
|
throw new QuotaRestrictedError('Series Quota exceeded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await mediaRepository.save(media);
|
||||||
|
|
||||||
|
const request = new MediaRequest({
|
||||||
|
type: MediaType.TV,
|
||||||
|
media,
|
||||||
|
requestedBy: requestUser,
|
||||||
|
// If the user is an admin or has the "auto approve" permission, automatically approve the request
|
||||||
|
status: user.hasPermission(
|
||||||
|
[
|
||||||
|
requestBody.is4k
|
||||||
|
? Permission.AUTO_APPROVE_4K
|
||||||
|
: Permission.AUTO_APPROVE,
|
||||||
|
requestBody.is4k
|
||||||
|
? Permission.AUTO_APPROVE_4K_TV
|
||||||
|
: Permission.AUTO_APPROVE_TV,
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
],
|
||||||
|
{ type: 'or' }
|
||||||
|
)
|
||||||
|
? MediaRequestStatus.APPROVED
|
||||||
|
: MediaRequestStatus.PENDING,
|
||||||
|
modifiedBy: user.hasPermission(
|
||||||
|
[
|
||||||
|
requestBody.is4k
|
||||||
|
? Permission.AUTO_APPROVE_4K
|
||||||
|
: Permission.AUTO_APPROVE,
|
||||||
|
requestBody.is4k
|
||||||
|
? Permission.AUTO_APPROVE_4K_TV
|
||||||
|
: Permission.AUTO_APPROVE_TV,
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
],
|
||||||
|
{ type: 'or' }
|
||||||
|
)
|
||||||
|
? user
|
||||||
|
: undefined,
|
||||||
|
is4k: requestBody.is4k,
|
||||||
|
serverId: requestBody.serverId,
|
||||||
|
profileId: requestBody.profileId,
|
||||||
|
rootFolder: requestBody.rootFolder,
|
||||||
|
languageProfileId: requestBody.languageProfileId,
|
||||||
|
tags: requestBody.tags,
|
||||||
|
seasons: finalSeasons.map(
|
||||||
|
(sn) =>
|
||||||
|
new SeasonRequest({
|
||||||
|
seasonNumber: sn,
|
||||||
|
status: user.hasPermission(
|
||||||
|
[
|
||||||
|
requestBody.is4k
|
||||||
|
? Permission.AUTO_APPROVE_4K
|
||||||
|
: Permission.AUTO_APPROVE,
|
||||||
|
requestBody.is4k
|
||||||
|
? Permission.AUTO_APPROVE_4K_TV
|
||||||
|
: Permission.AUTO_APPROVE_TV,
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
],
|
||||||
|
{ type: 'or' }
|
||||||
|
)
|
||||||
|
? MediaRequestStatus.APPROVED
|
||||||
|
: MediaRequestStatus.PENDING,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
isAutoRequest: options.isAutoRequest ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await requestRepository.save(request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
public id: number;
|
public id: number;
|
||||||
|
|
||||||
@@ -119,6 +455,9 @@ export class MediaRequest {
|
|||||||
})
|
})
|
||||||
public tags?: number[];
|
public tags?: number[];
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
public isAutoRequest: boolean;
|
||||||
|
|
||||||
constructor(init?: Partial<MediaRequest>) {
|
constructor(init?: Partial<MediaRequest>) {
|
||||||
Object.assign(this, init);
|
Object.assign(this, init);
|
||||||
}
|
}
|
||||||
@@ -146,6 +485,10 @@ export class MediaRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.sendNotification(media, Notification.MEDIA_PENDING);
|
this.sendNotification(media, Notification.MEDIA_PENDING);
|
||||||
|
|
||||||
|
if (this.isAutoRequest) {
|
||||||
|
this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +533,14 @@ export class MediaRequest {
|
|||||||
: Notification.MEDIA_APPROVED
|
: Notification.MEDIA_APPROVED
|
||||||
: Notification.MEDIA_DECLINED
|
: Notification.MEDIA_DECLINED
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.status === MediaRequestStatus.APPROVED &&
|
||||||
|
autoApproved &&
|
||||||
|
this.isAutoRequest
|
||||||
|
) {
|
||||||
|
this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +702,7 @@ export class MediaRequest {
|
|||||||
|
|
||||||
let rootFolder = radarrSettings.activeDirectory;
|
let rootFolder = radarrSettings.activeDirectory;
|
||||||
let qualityProfile = radarrSettings.activeProfileId;
|
let qualityProfile = radarrSettings.activeProfileId;
|
||||||
let tags = radarrSettings.tags;
|
let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.rootFolder &&
|
this.rootFolder &&
|
||||||
@@ -411,10 +762,51 @@ export class MediaRequest {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (radarrSettings.tagRequests) {
|
||||||
|
let userTag = (await radarr.getTags()).find((v) =>
|
||||||
|
v.label.startsWith(this.requestedBy.id + ' - ')
|
||||||
|
);
|
||||||
|
if (!userTag) {
|
||||||
|
logger.info(`Requester has no active tag. Creating new`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
userId: this.requestedBy.id,
|
||||||
|
newTag:
|
||||||
|
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
||||||
|
});
|
||||||
|
userTag = await radarr.createTag({
|
||||||
|
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (userTag.id) {
|
||||||
|
if (!tags?.find((v) => v === userTag?.id)) {
|
||||||
|
tags?.push(userTag.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`Requester has no tag and failed to add one`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
userId: this.requestedBy.id,
|
||||||
|
radarrServer: radarrSettings.hostname + ':' + radarrSettings.port,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||||
) {
|
) {
|
||||||
throw new Error('Media already available');
|
logger.warn('Media already exists, marking request as APPROVED', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
this.status = MediaRequestStatus.APPROVED;
|
||||||
|
await requestRepository.save(this);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const radarrMovieOptions: RadarrMovieOptions = {
|
const radarrMovieOptions: RadarrMovieOptions = {
|
||||||
@@ -555,7 +947,16 @@ export class MediaRequest {
|
|||||||
if (
|
if (
|
||||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||||
) {
|
) {
|
||||||
throw new Error('Media already available');
|
logger.warn('Media already exists, marking request as APPROVED', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
this.status = MediaRequestStatus.APPROVED;
|
||||||
|
await requestRepository.save(this);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
@@ -581,7 +982,7 @@ export class MediaRequest {
|
|||||||
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
seriesType = 'anime';
|
seriesType = sonarrSettings.animeSeriesType ?? 'anime';
|
||||||
}
|
}
|
||||||
|
|
||||||
let rootFolder =
|
let rootFolder =
|
||||||
@@ -599,7 +1000,11 @@ export class MediaRequest {
|
|||||||
let tags =
|
let tags =
|
||||||
seriesType === 'anime'
|
seriesType === 'anime'
|
||||||
? sonarrSettings.animeTags
|
? sonarrSettings.animeTags
|
||||||
: sonarrSettings.tags;
|
? [...sonarrSettings.animeTags]
|
||||||
|
: []
|
||||||
|
: sonarrSettings.tags
|
||||||
|
? [...sonarrSettings.tags]
|
||||||
|
: [];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.rootFolder &&
|
this.rootFolder &&
|
||||||
@@ -651,6 +1056,38 @@ export class MediaRequest {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sonarrSettings.tagRequests) {
|
||||||
|
let userTag = (await sonarr.getTags()).find((v) =>
|
||||||
|
v.label.startsWith(this.requestedBy.id + ' - ')
|
||||||
|
);
|
||||||
|
if (!userTag) {
|
||||||
|
logger.info(`Requester has no active tag. Creating new`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
userId: this.requestedBy.id,
|
||||||
|
newTag:
|
||||||
|
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
||||||
|
});
|
||||||
|
userTag = await sonarr.createTag({
|
||||||
|
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (userTag.id) {
|
||||||
|
if (!tags?.find((v) => v === userTag?.id)) {
|
||||||
|
tags?.push(userTag.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`Requester has no tag and failed to add one`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
userId: this.requestedBy.id,
|
||||||
|
sonarrServer: sonarrSettings.hostname + ':' + sonarrSettings.port,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sonarrSeriesOptions: AddSeriesOptions = {
|
const sonarrSeriesOptions: AddSeriesOptions = {
|
||||||
profileId: qualityProfile,
|
profileId: qualityProfile,
|
||||||
languageProfileId: languageProfile,
|
languageProfileId: languageProfile,
|
||||||
@@ -728,6 +1165,7 @@ export class MediaRequest {
|
|||||||
const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series';
|
const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series';
|
||||||
let event: string | undefined;
|
let event: string | undefined;
|
||||||
let notifyAdmin = true;
|
let notifyAdmin = true;
|
||||||
|
let notifySystem = true;
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case Notification.MEDIA_APPROVED:
|
case Notification.MEDIA_APPROVED:
|
||||||
@@ -741,6 +1179,13 @@ export class MediaRequest {
|
|||||||
case Notification.MEDIA_PENDING:
|
case Notification.MEDIA_PENDING:
|
||||||
event = `New ${this.is4k ? '4K ' : ''}${mediaType} Request`;
|
event = `New ${this.is4k ? '4K ' : ''}${mediaType} Request`;
|
||||||
break;
|
break;
|
||||||
|
case Notification.MEDIA_AUTO_REQUESTED:
|
||||||
|
event = `${
|
||||||
|
this.is4k ? '4K ' : ''
|
||||||
|
}${mediaType} Request Automatically Submitted`;
|
||||||
|
notifyAdmin = false;
|
||||||
|
notifySystem = false;
|
||||||
|
break;
|
||||||
case Notification.MEDIA_AUTO_APPROVED:
|
case Notification.MEDIA_AUTO_APPROVED:
|
||||||
event = `${
|
event = `${
|
||||||
this.is4k ? '4K ' : ''
|
this.is4k ? '4K ' : ''
|
||||||
@@ -757,6 +1202,7 @@ export class MediaRequest {
|
|||||||
media,
|
media,
|
||||||
request: this,
|
request: this,
|
||||||
notifyAdmin,
|
notifyAdmin,
|
||||||
|
notifySystem,
|
||||||
notifyUser: notifyAdmin ? undefined : this.requestedBy,
|
notifyUser: notifyAdmin ? undefined : this.requestedBy,
|
||||||
event,
|
event,
|
||||||
subject: `${movie.title}${
|
subject: `${movie.title}${
|
||||||
@@ -775,6 +1221,7 @@ export class MediaRequest {
|
|||||||
media,
|
media,
|
||||||
request: this,
|
request: this,
|
||||||
notifyAdmin,
|
notifyAdmin,
|
||||||
|
notifySystem,
|
||||||
notifyUser: notifyAdmin ? undefined : this.requestedBy,
|
notifyUser: notifyAdmin ? undefined : this.requestedBy,
|
||||||
event,
|
event,
|
||||||
subject: `${tv.name}${
|
subject: `${tv.name}${
|
||||||
@@ -806,3 +1253,5 @@ export class MediaRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default MediaRequest;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
|
import { MediaStatus } from '@server/constants/media';
|
||||||
import {
|
import {
|
||||||
Entity,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
Column,
|
Column,
|
||||||
ManyToOne,
|
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { MediaStatus } from '../constants/media';
|
|
||||||
import Media from './Media';
|
import Media from './Media';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
|
import { MediaRequestStatus } from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
import {
|
import {
|
||||||
Entity,
|
AfterRemove,
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
UpdateDateColumn,
|
Entity,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { MediaRequestStatus } from '../constants/media';
|
|
||||||
import { MediaRequest } from './MediaRequest';
|
import { MediaRequest } from './MediaRequest';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
@@ -34,6 +36,18 @@ class SeasonRequest {
|
|||||||
constructor(init?: Partial<SeasonRequest>) {
|
constructor(init?: Partial<SeasonRequest>) {
|
||||||
Object.assign(this, init);
|
Object.assign(this, init);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@AfterRemove()
|
||||||
|
public async handleRemoveParent(): Promise<void> {
|
||||||
|
const mediaRequestRepository = getRepository(MediaRequest);
|
||||||
|
const requestToBeDeleted = await mediaRequestRepository.findOneOrFail({
|
||||||
|
where: { id: this.request.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requestToBeDeleted.seasons.length === 0) {
|
||||||
|
await mediaRequestRepository.delete({ id: this.request.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SeasonRequest;
|
export default SeasonRequest;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ISession } from 'connect-typeorm';
|
import type { ISession } from 'connect-typeorm';
|
||||||
import { Index, Column, PrimaryColumn, Entity } from 'typeorm';
|
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class Session implements ISession {
|
export class Session implements ISession {
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
import { MediaRequestStatus, MediaType } from '@server/constants/media';
|
||||||
|
import { UserType } from '@server/constants/user';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
|
||||||
|
import PreparedEmail from '@server/lib/email';
|
||||||
|
import type { PermissionCheckOptions } from '@server/lib/permissions';
|
||||||
|
import { hasPermission, Permission } from '@server/lib/permissions';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import { AfterDate } from '@server/utils/dateHelpers';
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -14,16 +24,6 @@ import {
|
|||||||
RelationCount,
|
RelationCount,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { MediaRequestStatus, MediaType } from '../constants/media';
|
|
||||||
import { UserType } from '../constants/user';
|
|
||||||
import { getRepository } from '../datasource';
|
|
||||||
import type { QuotaResponse } from '../interfaces/api/userInterfaces';
|
|
||||||
import PreparedEmail from '../lib/email';
|
|
||||||
import type { PermissionCheckOptions } from '../lib/permissions';
|
|
||||||
import { hasPermission, Permission } from '../lib/permissions';
|
|
||||||
import { getSettings } from '../lib/settings';
|
|
||||||
import logger from '../logger';
|
|
||||||
import { AfterDate } from '../utils/dateHelpers';
|
|
||||||
import Issue from './Issue';
|
import Issue from './Issue';
|
||||||
import { MediaRequest } from './MediaRequest';
|
import { MediaRequest } from './MediaRequest';
|
||||||
import SeasonRequest from './SeasonRequest';
|
import SeasonRequest from './SeasonRequest';
|
||||||
@@ -39,7 +39,7 @@ export class User {
|
|||||||
return users.map((u) => u.filter(showFiltered));
|
return users.map((u) => u.filter(showFiltered));
|
||||||
}
|
}
|
||||||
|
|
||||||
static readonly filteredFields: string[] = ['email'];
|
static readonly filteredFields: string[] = ['email', 'plexId'];
|
||||||
|
|
||||||
public displayName: string;
|
public displayName: string;
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ export class User {
|
|||||||
@Column({ type: 'integer', default: UserType.PLEX })
|
@Column({ type: 'integer', default: UserType.PLEX })
|
||||||
public userType: UserType;
|
public userType: UserType;
|
||||||
|
|
||||||
@Column({ nullable: true, select: false })
|
@Column({ nullable: true, select: true })
|
||||||
public plexId?: number;
|
public plexId?: number;
|
||||||
|
|
||||||
@Column({ nullable: true, select: false })
|
@Column({ nullable: true, select: false })
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import type { NotificationAgentTypes } from '@server/interfaces/api/userSettingsInterfaces';
|
||||||
|
import { hasNotificationType, Notification } from '@server/lib/notifications';
|
||||||
|
import { NotificationAgentKey } from '@server/lib/settings';
|
||||||
import {
|
import {
|
||||||
Column,
|
Column,
|
||||||
Entity,
|
Entity,
|
||||||
@@ -5,9 +8,6 @@ import {
|
|||||||
OneToOne,
|
OneToOne,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import type { NotificationAgentTypes } from '../interfaces/api/userSettingsInterfaces';
|
|
||||||
import { hasNotificationType, Notification } from '../lib/notifications';
|
|
||||||
import { NotificationAgentKey } from '../lib/settings';
|
|
||||||
import { User } from './User';
|
import { User } from './User';
|
||||||
|
|
||||||
export const ALL_NOTIFICATIONS = Object.values(Notification)
|
export const ALL_NOTIFICATIONS = Object.values(Notification)
|
||||||
@@ -51,12 +51,21 @@ export class UserSettings {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public pushoverUserKey?: string;
|
public pushoverUserKey?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public pushoverSound?: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public telegramChatId?: string;
|
public telegramChatId?: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public telegramSendSilently?: boolean;
|
public telegramSendSilently?: boolean;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public watchlistSyncMovies?: boolean;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public watchlistSyncTv?: boolean;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
|
|||||||
@@ -1,3 +1,27 @@
|
|||||||
|
import PlexAPI from '@server/api/plexapi';
|
||||||
|
import dataSource, { getRepository } from '@server/datasource';
|
||||||
|
import DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||||
|
import { Session } from '@server/entity/Session';
|
||||||
|
import { User } from '@server/entity/User';
|
||||||
|
import { startJobs } from '@server/job/schedule';
|
||||||
|
import notificationManager from '@server/lib/notifications';
|
||||||
|
import DiscordAgent from '@server/lib/notifications/agents/discord';
|
||||||
|
import EmailAgent from '@server/lib/notifications/agents/email';
|
||||||
|
import GotifyAgent from '@server/lib/notifications/agents/gotify';
|
||||||
|
import LunaSeaAgent from '@server/lib/notifications/agents/lunasea';
|
||||||
|
import PushbulletAgent from '@server/lib/notifications/agents/pushbullet';
|
||||||
|
import PushoverAgent from '@server/lib/notifications/agents/pushover';
|
||||||
|
import SlackAgent from '@server/lib/notifications/agents/slack';
|
||||||
|
import TelegramAgent from '@server/lib/notifications/agents/telegram';
|
||||||
|
import WebhookAgent from '@server/lib/notifications/agents/webhook';
|
||||||
|
import WebPushAgent from '@server/lib/notifications/agents/webpush';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import clearCookies from '@server/middleware/clearcookies';
|
||||||
|
import routes from '@server/routes';
|
||||||
|
import imageproxy from '@server/routes/imageproxy';
|
||||||
|
import { getAppVersion } from '@server/utils/appVersion';
|
||||||
|
import restartFlag from '@server/utils/restartFlag';
|
||||||
import { getClientIp } from '@supercharge/request-ip';
|
import { getClientIp } from '@supercharge/request-ip';
|
||||||
import { TypeormStore } from 'connect-typeorm/out';
|
import { TypeormStore } from 'connect-typeorm/out';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
@@ -11,27 +35,6 @@ import next from 'next';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import swaggerUi from 'swagger-ui-express';
|
import swaggerUi from 'swagger-ui-express';
|
||||||
import YAML from 'yamljs';
|
import YAML from 'yamljs';
|
||||||
import PlexAPI from './api/plexapi';
|
|
||||||
import dataSource, { getRepository } from './datasource';
|
|
||||||
import { Session } from './entity/Session';
|
|
||||||
import { User } from './entity/User';
|
|
||||||
import { startJobs } from './job/schedule';
|
|
||||||
import notificationManager from './lib/notifications';
|
|
||||||
import DiscordAgent from './lib/notifications/agents/discord';
|
|
||||||
import EmailAgent from './lib/notifications/agents/email';
|
|
||||||
import GotifyAgent from './lib/notifications/agents/gotify';
|
|
||||||
import LunaSeaAgent from './lib/notifications/agents/lunasea';
|
|
||||||
import PushbulletAgent from './lib/notifications/agents/pushbullet';
|
|
||||||
import PushoverAgent from './lib/notifications/agents/pushover';
|
|
||||||
import SlackAgent from './lib/notifications/agents/slack';
|
|
||||||
import TelegramAgent from './lib/notifications/agents/telegram';
|
|
||||||
import WebhookAgent from './lib/notifications/agents/webhook';
|
|
||||||
import WebPushAgent from './lib/notifications/agents/webpush';
|
|
||||||
import { getSettings } from './lib/settings';
|
|
||||||
import logger from './logger';
|
|
||||||
import routes from './routes';
|
|
||||||
import { getAppVersion } from './utils/appVersion';
|
|
||||||
import restartFlag from './utils/restartFlag';
|
|
||||||
|
|
||||||
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
|
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
|
||||||
|
|
||||||
@@ -94,6 +97,9 @@ app
|
|||||||
// Start Jobs
|
// Start Jobs
|
||||||
startJobs();
|
startJobs();
|
||||||
|
|
||||||
|
// Bootstrap Discovery Sliders
|
||||||
|
await DiscoverSlider.bootstrapSliders();
|
||||||
|
|
||||||
const server = express();
|
const server = express();
|
||||||
if (settings.main.trustProxy) {
|
if (settings.main.trustProxy) {
|
||||||
server.enable('trust proxy');
|
server.enable('trust proxy');
|
||||||
@@ -146,7 +152,7 @@ app
|
|||||||
cookie: {
|
cookie: {
|
||||||
maxAge: 1000 * 60 * 60 * 24 * 30,
|
maxAge: 1000 * 60 * 60 * 24 * 30,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: true,
|
sameSite: settings.main.csrfProtection ? 'strict' : 'lax',
|
||||||
secure: 'auto',
|
secure: 'auto',
|
||||||
},
|
},
|
||||||
store: new TypeormStore({
|
store: new TypeormStore({
|
||||||
@@ -176,6 +182,10 @@ app
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
server.use('/api/v1', routes);
|
server.use('/api/v1', routes);
|
||||||
|
|
||||||
|
// Do not set cookies so CDNs can cache them
|
||||||
|
server.use('/imageproxy', clearCookies, imageproxy);
|
||||||
|
|
||||||
server.get('*', (req, res) => handle(req, res));
|
server.get('*', (req, res) => handle(req, res));
|
||||||
server.use(
|
server.use(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -3,3 +3,17 @@ export interface GenreSliderItem {
|
|||||||
name: string;
|
name: string;
|
||||||
backdrops: string[];
|
backdrops: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WatchlistItem {
|
||||||
|
ratingKey: string;
|
||||||
|
tmdbId: number;
|
||||||
|
mediaType: 'movie' | 'tv';
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WatchlistResponse {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalResults: number;
|
||||||
|
results: WatchlistItem[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type Issue from '../../entity/Issue';
|
import type Issue from '@server/entity/Issue';
|
||||||
import type { PaginatedResponse } from './common';
|
import type { PaginatedResponse } from './common';
|
||||||
|
|
||||||
export interface IssueResultsResponse extends PaginatedResponse {
|
export interface IssueResultsResponse extends PaginatedResponse {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type Media from '../../entity/Media';
|
import type Media from '@server/entity/Media';
|
||||||
import type { User } from '../../entity/User';
|
import type { User } from '@server/entity/User';
|
||||||
import type { PaginatedResponse } from './common';
|
import type { PaginatedResponse } from './common';
|
||||||
|
|
||||||
export interface MediaResultsResponse extends PaginatedResponse {
|
export interface MediaResultsResponse extends PaginatedResponse {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PersonCreditCast, PersonCreditCrew } from '../../models/Person';
|
import type { PersonCreditCast, PersonCreditCrew } from '@server/models/Person';
|
||||||
|
|
||||||
export interface PersonCombinedCreditsResponse {
|
export interface PersonCombinedCreditsResponse {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PlexSettings } from '../../lib/settings';
|
import type { PlexSettings } from '@server/lib/settings';
|
||||||
|
|
||||||
export interface PlexStatus {
|
export interface PlexStatus {
|
||||||
settings: PlexSettings;
|
settings: PlexSettings;
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
|
import type { MediaType } from '@server/constants/media';
|
||||||
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
import type { PaginatedResponse } from './common';
|
import type { PaginatedResponse } from './common';
|
||||||
import type { MediaRequest } from '../../entity/MediaRequest';
|
|
||||||
|
|
||||||
export interface RequestResultsResponse extends PaginatedResponse {
|
export interface RequestResultsResponse extends PaginatedResponse {
|
||||||
results: MediaRequest[];
|
results: MediaRequest[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MediaRequestBody = {
|
||||||
|
mediaType: MediaType;
|
||||||
|
mediaId: number;
|
||||||
|
tvdbId?: number;
|
||||||
|
seasons?: number[] | 'all';
|
||||||
|
is4k?: boolean;
|
||||||
|
serverId?: number;
|
||||||
|
profileId?: number;
|
||||||
|
rootFolder?: string;
|
||||||
|
languageProfileId?: number;
|
||||||
|
userId?: number;
|
||||||
|
tags?: number[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { QualityProfile, RootFolder, Tag } from '../../api/servarr/base';
|
import type { QualityProfile, RootFolder, Tag } from '@server/api/servarr/base';
|
||||||
import type { LanguageProfile } from '../../api/servarr/sonarr';
|
import type { LanguageProfile } from '@server/api/servarr/sonarr';
|
||||||
|
|
||||||
export interface ServiceCommonServer {
|
export interface ServiceCommonServer {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ export interface CacheItem {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CacheResponse {
|
||||||
|
apiCaches: CacheItem[];
|
||||||
|
imageCache: Record<'tmdb', { size: number; imageCount: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StatusResponse {
|
export interface StatusResponse {
|
||||||
version: string;
|
version: string;
|
||||||
commitTag: string;
|
commitTag: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type Media from '../../entity/Media';
|
import type Media from '@server/entity/Media';
|
||||||
import type { MediaRequest } from '../../entity/MediaRequest';
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
import type { User } from '../../entity/User';
|
import type { User } from '@server/entity/User';
|
||||||
import type { PaginatedResponse } from './common';
|
import type { PaginatedResponse } from './common';
|
||||||
|
|
||||||
export interface UserResultsResponse extends PaginatedResponse {
|
export interface UserResultsResponse extends PaginatedResponse {
|
||||||
@@ -23,6 +23,7 @@ export interface QuotaResponse {
|
|||||||
movie: QuotaStatus;
|
movie: QuotaStatus;
|
||||||
tv: QuotaStatus;
|
tv: QuotaStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserWatchDataResponse {
|
export interface UserWatchDataResponse {
|
||||||
recentlyWatched: Media[];
|
recentlyWatched: Media[];
|
||||||
playCount: number;
|
playCount: number;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { NotificationAgentKey } from '../../lib/settings';
|
import type { NotificationAgentKey } from '@server/lib/settings';
|
||||||
|
|
||||||
export interface UserSettingsGeneralResponse {
|
export interface UserSettingsGeneralResponse {
|
||||||
username?: string;
|
username?: string;
|
||||||
@@ -14,6 +14,8 @@ export interface UserSettingsGeneralResponse {
|
|||||||
globalMovieQuotaLimit?: number;
|
globalMovieQuotaLimit?: number;
|
||||||
globalTvQuotaLimit?: number;
|
globalTvQuotaLimit?: number;
|
||||||
globalTvQuotaDays?: number;
|
globalTvQuotaDays?: number;
|
||||||
|
watchlistSyncMovies?: boolean;
|
||||||
|
watchlistSyncTv?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NotificationAgentTypes = Record<NotificationAgentKey, number>;
|
export type NotificationAgentTypes = Record<NotificationAgentKey, number>;
|
||||||
@@ -26,6 +28,7 @@ export interface UserSettingsNotificationsResponse {
|
|||||||
pushbulletAccessToken?: string;
|
pushbulletAccessToken?: string;
|
||||||
pushoverApplicationToken?: string;
|
pushoverApplicationToken?: string;
|
||||||
pushoverUserKey?: string;
|
pushoverUserKey?: string;
|
||||||
|
pushoverSound?: string;
|
||||||
telegramEnabled?: boolean;
|
telegramEnabled?: boolean;
|
||||||
telegramBotUsername?: string;
|
telegramBotUsername?: string;
|
||||||
telegramChatId?: string;
|
telegramChatId?: string;
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
|
import availabilitySync from '@server/lib/availabilitySync';
|
||||||
|
import downloadTracker from '@server/lib/downloadtracker';
|
||||||
|
import ImageProxy from '@server/lib/imageproxy';
|
||||||
|
import refreshToken from '@server/lib/refreshToken';
|
||||||
|
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
|
||||||
|
import { radarrScanner } from '@server/lib/scanners/radarr';
|
||||||
|
import { sonarrScanner } from '@server/lib/scanners/sonarr';
|
||||||
|
import type { JobId } from '@server/lib/settings';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import watchlistSync from '@server/lib/watchlistsync';
|
||||||
|
import logger from '@server/logger';
|
||||||
import schedule from 'node-schedule';
|
import schedule from 'node-schedule';
|
||||||
import downloadTracker from '../lib/downloadtracker';
|
|
||||||
import { plexFullScanner, plexRecentScanner } from '../lib/scanners/plex';
|
|
||||||
import { radarrScanner } from '../lib/scanners/radarr';
|
|
||||||
import { sonarrScanner } from '../lib/scanners/sonarr';
|
|
||||||
import type { JobId } from '../lib/settings';
|
|
||||||
import { getSettings } from '../lib/settings';
|
|
||||||
import logger from '../logger';
|
|
||||||
|
|
||||||
interface ScheduledJob {
|
interface ScheduledJob {
|
||||||
id: JobId;
|
id: JobId;
|
||||||
job: schedule.Job;
|
job: schedule.Job;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'process' | 'command';
|
type: 'process' | 'command';
|
||||||
interval: 'short' | 'long' | 'fixed';
|
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
|
||||||
|
cronSchedule: string;
|
||||||
running?: () => boolean;
|
running?: () => boolean;
|
||||||
cancelFn?: () => void;
|
cancelFn?: () => void;
|
||||||
}
|
}
|
||||||
@@ -27,7 +32,8 @@ export const startJobs = (): void => {
|
|||||||
id: 'plex-recently-added-scan',
|
id: 'plex-recently-added-scan',
|
||||||
name: 'Plex Recently Added Scan',
|
name: 'Plex Recently Added Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'short',
|
interval: 'minutes',
|
||||||
|
cronSchedule: jobs['plex-recently-added-scan'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['plex-recently-added-scan'].schedule, () => {
|
job: schedule.scheduleJob(jobs['plex-recently-added-scan'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Plex Recently Added Scan', {
|
logger.info('Starting scheduled job: Plex Recently Added Scan', {
|
||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
@@ -43,7 +49,8 @@ export const startJobs = (): void => {
|
|||||||
id: 'plex-full-scan',
|
id: 'plex-full-scan',
|
||||||
name: 'Plex Full Library Scan',
|
name: 'Plex Full Library Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'long',
|
interval: 'hours',
|
||||||
|
cronSchedule: jobs['plex-full-scan'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => {
|
job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Plex Full Library Scan', {
|
logger.info('Starting scheduled job: Plex Full Library Scan', {
|
||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
@@ -54,12 +61,28 @@ export const startJobs = (): void => {
|
|||||||
cancelFn: () => plexFullScanner.cancel(),
|
cancelFn: () => plexFullScanner.cancel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Watchlist Sync
|
||||||
|
scheduledJobs.push({
|
||||||
|
id: 'plex-watchlist-sync',
|
||||||
|
name: 'Plex Watchlist Sync',
|
||||||
|
type: 'process',
|
||||||
|
interval: 'seconds',
|
||||||
|
cronSchedule: jobs['plex-watchlist-sync'].schedule,
|
||||||
|
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
|
||||||
|
logger.info('Starting scheduled job: Plex Watchlist Sync', {
|
||||||
|
label: 'Jobs',
|
||||||
|
});
|
||||||
|
watchlistSync.syncWatchlist();
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
// Run full radarr scan every 24 hours
|
// Run full radarr scan every 24 hours
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'radarr-scan',
|
id: 'radarr-scan',
|
||||||
name: 'Radarr Scan',
|
name: 'Radarr Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'long',
|
interval: 'hours',
|
||||||
|
cronSchedule: jobs['radarr-scan'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => {
|
job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
|
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
|
||||||
radarrScanner.run();
|
radarrScanner.run();
|
||||||
@@ -73,7 +96,8 @@ export const startJobs = (): void => {
|
|||||||
id: 'sonarr-scan',
|
id: 'sonarr-scan',
|
||||||
name: 'Sonarr Scan',
|
name: 'Sonarr Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'long',
|
interval: 'hours',
|
||||||
|
cronSchedule: jobs['sonarr-scan'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => {
|
job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
|
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
|
||||||
sonarrScanner.run();
|
sonarrScanner.run();
|
||||||
@@ -82,12 +106,30 @@ export const startJobs = (): void => {
|
|||||||
cancelFn: () => sonarrScanner.cancel(),
|
cancelFn: () => sonarrScanner.cancel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Checks if media is still available in plex/sonarr/radarr libs
|
||||||
|
scheduledJobs.push({
|
||||||
|
id: 'availability-sync',
|
||||||
|
name: 'Media Availability Sync',
|
||||||
|
type: 'process',
|
||||||
|
interval: 'hours',
|
||||||
|
cronSchedule: jobs['availability-sync'].schedule,
|
||||||
|
job: schedule.scheduleJob(jobs['availability-sync'].schedule, () => {
|
||||||
|
logger.info('Starting scheduled job: Media Availability Sync', {
|
||||||
|
label: 'Jobs',
|
||||||
|
});
|
||||||
|
availabilitySync.run();
|
||||||
|
}),
|
||||||
|
running: () => availabilitySync.running,
|
||||||
|
cancelFn: () => availabilitySync.cancel(),
|
||||||
|
});
|
||||||
|
|
||||||
// Run download sync every minute
|
// Run download sync every minute
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'download-sync',
|
id: 'download-sync',
|
||||||
name: 'Download Sync',
|
name: 'Download Sync',
|
||||||
type: 'command',
|
type: 'command',
|
||||||
interval: 'fixed',
|
interval: 'seconds',
|
||||||
|
cronSchedule: jobs['download-sync'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['download-sync'].schedule, () => {
|
job: schedule.scheduleJob(jobs['download-sync'].schedule, () => {
|
||||||
logger.debug('Starting scheduled job: Download Sync', {
|
logger.debug('Starting scheduled job: Download Sync', {
|
||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
@@ -101,7 +143,8 @@ export const startJobs = (): void => {
|
|||||||
id: 'download-sync-reset',
|
id: 'download-sync-reset',
|
||||||
name: 'Download Sync Reset',
|
name: 'Download Sync Reset',
|
||||||
type: 'command',
|
type: 'command',
|
||||||
interval: 'long',
|
interval: 'hours',
|
||||||
|
cronSchedule: jobs['download-sync-reset'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => {
|
job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Download Sync Reset', {
|
logger.info('Starting scheduled job: Download Sync Reset', {
|
||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
@@ -110,5 +153,35 @@ export const startJobs = (): void => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Run image cache cleanup every 24 hours
|
||||||
|
scheduledJobs.push({
|
||||||
|
id: 'image-cache-cleanup',
|
||||||
|
name: 'Image Cache Cleanup',
|
||||||
|
type: 'process',
|
||||||
|
interval: 'hours',
|
||||||
|
cronSchedule: jobs['image-cache-cleanup'].schedule,
|
||||||
|
job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => {
|
||||||
|
logger.info('Starting scheduled job: Image Cache Cleanup', {
|
||||||
|
label: 'Jobs',
|
||||||
|
});
|
||||||
|
// Clean TMDB image cache
|
||||||
|
ImageProxy.clearCache('tmdb');
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
scheduledJobs.push({
|
||||||
|
id: 'plex-refresh-token',
|
||||||
|
name: 'Plex Refresh Token',
|
||||||
|
type: 'process',
|
||||||
|
interval: 'fixed',
|
||||||
|
cronSchedule: jobs['plex-refresh-token'].schedule,
|
||||||
|
job: schedule.scheduleJob(jobs['plex-refresh-token'].schedule, () => {
|
||||||
|
logger.info('Starting scheduled job: Plex Refresh Token', {
|
||||||
|
label: 'Jobs',
|
||||||
|
});
|
||||||
|
refreshToken.run();
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
|
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
|
||||||
};
|
};
|
||||||
|
|||||||
725
server/lib/availabilitySync.ts
Normal file
725
server/lib/availabilitySync.ts
Normal file
@@ -0,0 +1,725 @@
|
|||||||
|
import type { PlexMetadata } from '@server/api/plexapi';
|
||||||
|
import PlexAPI from '@server/api/plexapi';
|
||||||
|
import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr';
|
||||||
|
import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr';
|
||||||
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
|
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import Media from '@server/entity/Media';
|
||||||
|
import MediaRequest from '@server/entity/MediaRequest';
|
||||||
|
import type Season from '@server/entity/Season';
|
||||||
|
import SeasonRequest from '@server/entity/SeasonRequest';
|
||||||
|
import { User } from '@server/entity/User';
|
||||||
|
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
|
||||||
|
class AvailabilitySync {
|
||||||
|
public running = false;
|
||||||
|
private plexClient: PlexAPI;
|
||||||
|
private plexSeasonsCache: Record<string, PlexMetadata[]>;
|
||||||
|
private sonarrSeasonsCache: Record<string, SonarrSeason[]>;
|
||||||
|
private radarrServers: RadarrSettings[];
|
||||||
|
private sonarrServers: SonarrSettings[];
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
const settings = getSettings();
|
||||||
|
this.running = true;
|
||||||
|
this.plexSeasonsCache = {};
|
||||||
|
this.sonarrSeasonsCache = {};
|
||||||
|
this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
|
||||||
|
this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(`Starting availability sync...`, {
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
});
|
||||||
|
const pageSize = 50;
|
||||||
|
|
||||||
|
const userRepository = getRepository(User);
|
||||||
|
const admin = await userRepository.findOne({
|
||||||
|
select: { id: true, plexToken: true },
|
||||||
|
where: { id: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (admin) {
|
||||||
|
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
|
||||||
|
} else {
|
||||||
|
logger.error('An admin is not configured.');
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const media of this.loadAvailableMediaPaginated(pageSize)) {
|
||||||
|
if (!this.running) {
|
||||||
|
throw new Error('Job aborted');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check plex, radarr, and sonarr for that specific media and
|
||||||
|
// if unavailable, then we change the status accordingly.
|
||||||
|
// If a non-4k or 4k version exists in at least one of the instances, we will only update that specific version
|
||||||
|
if (media.mediaType === 'movie') {
|
||||||
|
let movieExists = false;
|
||||||
|
let movieExists4k = false;
|
||||||
|
|
||||||
|
const { existsInPlex } = await this.mediaExistsInPlex(media, false);
|
||||||
|
const { existsInPlex: existsInPlex4k } = await this.mediaExistsInPlex(
|
||||||
|
media,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
const existsInRadarr = await this.mediaExistsInRadarr(media, false);
|
||||||
|
const existsInRadarr4k = await this.mediaExistsInRadarr(media, true);
|
||||||
|
|
||||||
|
if (existsInPlex || existsInRadarr) {
|
||||||
|
movieExists = true;
|
||||||
|
logger.info(
|
||||||
|
`The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||||
|
{
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsInPlex4k || existsInRadarr4k) {
|
||||||
|
movieExists4k = true;
|
||||||
|
logger.info(
|
||||||
|
`The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||||
|
{
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!movieExists && media.status === MediaStatus.AVAILABLE) {
|
||||||
|
await this.mediaUpdater(media, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!movieExists4k && media.status4k === MediaStatus.AVAILABLE) {
|
||||||
|
await this.mediaUpdater(media, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If both versions still exist in plex, we still need
|
||||||
|
// to check through sonarr to verify season availability
|
||||||
|
if (media.mediaType === 'tv') {
|
||||||
|
let showExists = false;
|
||||||
|
let showExists4k = false;
|
||||||
|
|
||||||
|
const { existsInPlex, seasonsMap: plexSeasonsMap = new Map() } =
|
||||||
|
await this.mediaExistsInPlex(media, false);
|
||||||
|
const {
|
||||||
|
existsInPlex: existsInPlex4k,
|
||||||
|
seasonsMap: plexSeasonsMap4k = new Map(),
|
||||||
|
} = await this.mediaExistsInPlex(media, true);
|
||||||
|
|
||||||
|
const { existsInSonarr, seasonsMap: sonarrSeasonsMap } =
|
||||||
|
await this.mediaExistsInSonarr(media, false);
|
||||||
|
const {
|
||||||
|
existsInSonarr: existsInSonarr4k,
|
||||||
|
seasonsMap: sonarrSeasonsMap4k,
|
||||||
|
} = await this.mediaExistsInSonarr(media, true);
|
||||||
|
|
||||||
|
if (existsInPlex || existsInSonarr) {
|
||||||
|
showExists = true;
|
||||||
|
logger.info(
|
||||||
|
`The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||||
|
{
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsInPlex4k || existsInSonarr4k) {
|
||||||
|
showExists4k = true;
|
||||||
|
logger.info(
|
||||||
|
`The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||||
|
{
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Here we will create a final map that will cross compare
|
||||||
|
// with plex and sonarr. Filtered seasons will go through
|
||||||
|
// each season and assume the season does not exist. If Plex or
|
||||||
|
// Sonarr finds that season, we will change the final seasons value
|
||||||
|
// to true.
|
||||||
|
const filteredSeasonsMap: Map<number, boolean> = new Map();
|
||||||
|
|
||||||
|
media.seasons
|
||||||
|
.filter(
|
||||||
|
(season) =>
|
||||||
|
season.status === MediaStatus.AVAILABLE ||
|
||||||
|
season.status === MediaStatus.PARTIALLY_AVAILABLE
|
||||||
|
)
|
||||||
|
.forEach((season) =>
|
||||||
|
filteredSeasonsMap.set(season.seasonNumber, false)
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalSeasons = new Map([
|
||||||
|
...filteredSeasonsMap,
|
||||||
|
...plexSeasonsMap,
|
||||||
|
...sonarrSeasonsMap,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const filteredSeasonsMap4k: Map<number, boolean> = new Map();
|
||||||
|
|
||||||
|
media.seasons
|
||||||
|
.filter(
|
||||||
|
(season) =>
|
||||||
|
season.status4k === MediaStatus.AVAILABLE ||
|
||||||
|
season.status4k === MediaStatus.PARTIALLY_AVAILABLE
|
||||||
|
)
|
||||||
|
.forEach((season) =>
|
||||||
|
filteredSeasonsMap4k.set(season.seasonNumber, false)
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalSeasons4k = new Map([
|
||||||
|
...filteredSeasonsMap4k,
|
||||||
|
...plexSeasonsMap4k,
|
||||||
|
...sonarrSeasonsMap4k,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ([...finalSeasons.values()].includes(false)) {
|
||||||
|
await this.seasonUpdater(media, finalSeasons, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([...finalSeasons4k.values()].includes(false)) {
|
||||||
|
await this.seasonUpdater(media, finalSeasons4k, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!showExists &&
|
||||||
|
(media.status === MediaStatus.AVAILABLE ||
|
||||||
|
media.status === MediaStatus.PARTIALLY_AVAILABLE)
|
||||||
|
) {
|
||||||
|
await this.mediaUpdater(media, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!showExists4k &&
|
||||||
|
(media.status4k === MediaStatus.AVAILABLE ||
|
||||||
|
media.status4k === MediaStatus.PARTIALLY_AVAILABLE)
|
||||||
|
) {
|
||||||
|
await this.mediaUpdater(media, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
logger.error('Failed to complete availability sync.', {
|
||||||
|
errorMessage: ex.message,
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
logger.info(`Availability sync complete.`, {
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
});
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public cancel() {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async *loadAvailableMediaPaginated(pageSize: number) {
|
||||||
|
let offset = 0;
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const whereOptions = [
|
||||||
|
{ status: MediaStatus.AVAILABLE },
|
||||||
|
{ status: MediaStatus.PARTIALLY_AVAILABLE },
|
||||||
|
{ status4k: MediaStatus.AVAILABLE },
|
||||||
|
{ status4k: MediaStatus.PARTIALLY_AVAILABLE },
|
||||||
|
];
|
||||||
|
|
||||||
|
let mediaPage: Media[];
|
||||||
|
|
||||||
|
do {
|
||||||
|
yield* (mediaPage = await mediaRepository.find({
|
||||||
|
where: whereOptions,
|
||||||
|
skip: offset,
|
||||||
|
take: pageSize,
|
||||||
|
}));
|
||||||
|
offset += pageSize;
|
||||||
|
} while (mediaPage.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findMediaStatus(
|
||||||
|
requests: MediaRequest[],
|
||||||
|
is4k: boolean
|
||||||
|
): MediaStatus {
|
||||||
|
const filteredRequests = requests.filter(
|
||||||
|
(request) => request.is4k === is4k
|
||||||
|
);
|
||||||
|
|
||||||
|
let mediaStatus: MediaStatus;
|
||||||
|
|
||||||
|
if (
|
||||||
|
filteredRequests.some(
|
||||||
|
(request) => request.status === MediaRequestStatus.APPROVED
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
mediaStatus = MediaStatus.PROCESSING;
|
||||||
|
} else if (
|
||||||
|
filteredRequests.some(
|
||||||
|
(request) => request.status === MediaRequestStatus.PENDING
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
mediaStatus = MediaStatus.PENDING;
|
||||||
|
} else {
|
||||||
|
mediaStatus = MediaStatus.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mediaUpdater(media: Media, is4k: boolean): Promise<void> {
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find all related requests only if
|
||||||
|
// the related media has an available status
|
||||||
|
const requests = await requestRepository
|
||||||
|
.createQueryBuilder('request')
|
||||||
|
.leftJoinAndSelect('request.media', 'media')
|
||||||
|
.where('(media.id = :id)', {
|
||||||
|
id: media.id,
|
||||||
|
})
|
||||||
|
.andWhere(
|
||||||
|
`(request.is4k = :is4k AND media.${
|
||||||
|
is4k ? 'status4k' : 'status'
|
||||||
|
} IN (:...mediaStatus))`,
|
||||||
|
{
|
||||||
|
mediaStatus: [
|
||||||
|
MediaStatus.AVAILABLE,
|
||||||
|
MediaStatus.PARTIALLY_AVAILABLE,
|
||||||
|
],
|
||||||
|
is4k: is4k,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
// Check if a season is processing or pending to
|
||||||
|
// make sure we set the media to the correct status
|
||||||
|
let mediaStatus = MediaStatus.UNKNOWN;
|
||||||
|
|
||||||
|
if (media.mediaType === 'tv') {
|
||||||
|
mediaStatus = this.findMediaStatus(requests, is4k);
|
||||||
|
}
|
||||||
|
|
||||||
|
media[is4k ? 'status4k' : 'status'] = mediaStatus;
|
||||||
|
media[is4k ? 'serviceId4k' : 'serviceId'] =
|
||||||
|
mediaStatus === MediaStatus.PROCESSING
|
||||||
|
? media[is4k ? 'serviceId4k' : 'serviceId']
|
||||||
|
: null;
|
||||||
|
media[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||||
|
mediaStatus === MediaStatus.PROCESSING
|
||||||
|
? media[is4k ? 'externalServiceId4k' : 'externalServiceId']
|
||||||
|
: null;
|
||||||
|
media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
||||||
|
mediaStatus === MediaStatus.PROCESSING
|
||||||
|
? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']
|
||||||
|
: null;
|
||||||
|
media[is4k ? 'ratingKey4k' : 'ratingKey'] =
|
||||||
|
mediaStatus === MediaStatus.PROCESSING
|
||||||
|
? media[is4k ? 'ratingKey4k' : 'ratingKey']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`The ${is4k ? '4K' : 'non-4K'} ${
|
||||||
|
media.mediaType === 'movie' ? 'movie' : 'show'
|
||||||
|
} [TMDB ID ${media.tmdbId}] was not found in any ${
|
||||||
|
media.mediaType === 'movie' ? 'Radarr' : 'Sonarr'
|
||||||
|
} and Plex instance. Status will be changed to unknown.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
|
||||||
|
await mediaRepository.save({ media, ...media });
|
||||||
|
|
||||||
|
// Only delete media request if type is movie.
|
||||||
|
// Type tv request deletion is handled
|
||||||
|
// in the season request entity
|
||||||
|
if (requests.length > 0 && media.mediaType === 'movie') {
|
||||||
|
await requestRepository.remove(requests);
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
logger.debug(
|
||||||
|
`Failure updating the ${is4k ? '4K' : 'non-4K'} ${
|
||||||
|
media.mediaType === 'tv' ? 'show' : 'movie'
|
||||||
|
} [TMDB ID ${media.tmdbId}].`,
|
||||||
|
{
|
||||||
|
errorMessage: ex.message,
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seasonUpdater(
|
||||||
|
media: Media,
|
||||||
|
seasons: Map<number, boolean>,
|
||||||
|
is4k: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||||
|
|
||||||
|
const seasonsPendingRemoval = new Map(
|
||||||
|
// Disabled linter as only the value is needed from the filter
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
[...seasons].filter(([_, exists]) => !exists)
|
||||||
|
);
|
||||||
|
const seasonKeys = [...seasonsPendingRemoval.keys()];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Need to check and see if there are any related season
|
||||||
|
// requests. If they are, we will need to delete them.
|
||||||
|
const seasonRequests = await seasonRequestRepository
|
||||||
|
.createQueryBuilder('seasonRequest')
|
||||||
|
.leftJoinAndSelect('seasonRequest.request', 'request')
|
||||||
|
.leftJoinAndSelect('request.media', 'media')
|
||||||
|
.where('(media.id = :id)', { id: media.id })
|
||||||
|
.andWhere(
|
||||||
|
'(request.is4k = :is4k AND seasonRequest.seasonNumber IN (:...seasonNumbers))',
|
||||||
|
{
|
||||||
|
seasonNumbers: seasonKeys,
|
||||||
|
is4k: is4k,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
for (const mediaSeason of media.seasons) {
|
||||||
|
if (seasonsPendingRemoval.has(mediaSeason.seasonNumber)) {
|
||||||
|
mediaSeason[is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.status === MediaStatus.AVAILABLE) {
|
||||||
|
media.status = MediaStatus.PARTIALLY_AVAILABLE;
|
||||||
|
logger.info(
|
||||||
|
`Marking the non-4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.status4k === MediaStatus.AVAILABLE) {
|
||||||
|
media.status4k = MediaStatus.PARTIALLY_AVAILABLE;
|
||||||
|
logger.info(
|
||||||
|
`Marking the 4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await mediaRepository.save({ media, ...media });
|
||||||
|
|
||||||
|
if (seasonRequests.length > 0) {
|
||||||
|
await seasonRequestRepository.remove(seasonRequests);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`The ${is4k ? '4K' : 'non-4K'} season(s) [${seasonKeys}] [TMDB ID ${
|
||||||
|
media.tmdbId
|
||||||
|
}] was not found in any ${
|
||||||
|
media.mediaType === 'tv' ? 'Sonarr' : 'Radarr'
|
||||||
|
} and Plex instance. Status will be changed to unknown.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
} catch (ex) {
|
||||||
|
logger.debug(
|
||||||
|
`Failure updating the ${
|
||||||
|
is4k ? '4K' : 'non-4K'
|
||||||
|
} season(s) [${seasonKeys}], TMDB ID ${media.tmdbId}.`,
|
||||||
|
{
|
||||||
|
errorMessage: ex.message,
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mediaExistsInRadarr(
|
||||||
|
media: Media,
|
||||||
|
is4k: boolean
|
||||||
|
): Promise<boolean> {
|
||||||
|
let existsInRadarr = false;
|
||||||
|
|
||||||
|
// Check for availability in all of the available radarr servers
|
||||||
|
// If any find the media, we will assume the media exists
|
||||||
|
for (const server of this.radarrServers) {
|
||||||
|
const radarrAPI = new RadarrAPI({
|
||||||
|
apiKey: server.apiKey,
|
||||||
|
url: RadarrAPI.buildUrl(server, '/api/v3'),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
let radarr: RadarrMovie | undefined;
|
||||||
|
|
||||||
|
if (!server.is4k && media.externalServiceId && !is4k) {
|
||||||
|
radarr = await radarrAPI.getMovie({
|
||||||
|
id: media.externalServiceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.is4k && media.externalServiceId4k && is4k) {
|
||||||
|
radarr = await radarrAPI.getMovie({
|
||||||
|
id: media.externalServiceId4k,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (radarr && radarr.hasFile) {
|
||||||
|
existsInRadarr = true;
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
if (!ex.message.includes('404')) {
|
||||||
|
existsInRadarr = true;
|
||||||
|
logger.debug(
|
||||||
|
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} movie [TMDB ID ${
|
||||||
|
media.tmdbId
|
||||||
|
}] from Radarr.`,
|
||||||
|
{
|
||||||
|
errorMessage: ex.message,
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return existsInRadarr;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mediaExistsInSonarr(
|
||||||
|
media: Media,
|
||||||
|
is4k: boolean
|
||||||
|
): Promise<{ existsInSonarr: boolean; seasonsMap: Map<number, boolean> }> {
|
||||||
|
let existsInSonarr = false;
|
||||||
|
let preventSeasonSearch = false;
|
||||||
|
|
||||||
|
// Check for availability in all of the available sonarr servers
|
||||||
|
// If any find the media, we will assume the media exists
|
||||||
|
for (const server of this.sonarrServers) {
|
||||||
|
const sonarrAPI = new SonarrAPI({
|
||||||
|
apiKey: server.apiKey,
|
||||||
|
url: SonarrAPI.buildUrl(server, '/api/v3'),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
let sonarr: SonarrSeries | undefined;
|
||||||
|
|
||||||
|
if (!server.is4k && media.externalServiceId && !is4k) {
|
||||||
|
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId);
|
||||||
|
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] =
|
||||||
|
sonarr.seasons;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.is4k && media.externalServiceId4k && is4k) {
|
||||||
|
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId4k);
|
||||||
|
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] =
|
||||||
|
sonarr.seasons;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sonarr && sonarr.statistics.episodeFileCount > 0) {
|
||||||
|
existsInSonarr = true;
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
if (!ex.message.includes('404')) {
|
||||||
|
existsInSonarr = true;
|
||||||
|
preventSeasonSearch = true;
|
||||||
|
logger.debug(
|
||||||
|
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} show [TMDB ID ${
|
||||||
|
media.tmdbId
|
||||||
|
}] from Sonarr.`,
|
||||||
|
{
|
||||||
|
errorMessage: ex.message,
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Here we check each season for availability
|
||||||
|
// If the API returns an error other than a 404,
|
||||||
|
// we will have to prevent the season check from happening
|
||||||
|
const seasonsMap: Map<number, boolean> = new Map();
|
||||||
|
|
||||||
|
if (!preventSeasonSearch) {
|
||||||
|
const filteredSeasons = media.seasons.filter(
|
||||||
|
(season) =>
|
||||||
|
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
|
||||||
|
season[is4k ? 'status4k' : 'status'] ===
|
||||||
|
MediaStatus.PARTIALLY_AVAILABLE
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const season of filteredSeasons) {
|
||||||
|
const seasonExists = await this.seasonExistsInSonarr(
|
||||||
|
media,
|
||||||
|
season,
|
||||||
|
is4k
|
||||||
|
);
|
||||||
|
|
||||||
|
if (seasonExists) {
|
||||||
|
seasonsMap.set(season.seasonNumber, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { existsInSonarr, seasonsMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seasonExistsInSonarr(
|
||||||
|
media: Media,
|
||||||
|
season: Season,
|
||||||
|
is4k: boolean
|
||||||
|
): Promise<boolean> {
|
||||||
|
let seasonExists = false;
|
||||||
|
|
||||||
|
// Check each sonarr instance to see if the media still exists
|
||||||
|
// If found, we will assume the media exists and prevent removal
|
||||||
|
// We can use the cache we built when we fetched the series with mediaExistsInSonarr
|
||||||
|
for (const server of this.sonarrServers) {
|
||||||
|
let sonarrSeasons: SonarrSeason[] | undefined;
|
||||||
|
|
||||||
|
if (media.externalServiceId && !is4k) {
|
||||||
|
sonarrSeasons =
|
||||||
|
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.externalServiceId4k && is4k) {
|
||||||
|
sonarrSeasons =
|
||||||
|
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasonIsAvailable = sonarrSeasons?.find(
|
||||||
|
({ seasonNumber, statistics }) =>
|
||||||
|
season.seasonNumber === seasonNumber &&
|
||||||
|
statistics?.episodeFileCount &&
|
||||||
|
statistics?.episodeFileCount > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (seasonIsAvailable && sonarrSeasons) {
|
||||||
|
seasonExists = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return seasonExists;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mediaExistsInPlex(
|
||||||
|
media: Media,
|
||||||
|
is4k: boolean
|
||||||
|
): Promise<{ existsInPlex: boolean; seasonsMap?: Map<number, boolean> }> {
|
||||||
|
const ratingKey = media.ratingKey;
|
||||||
|
const ratingKey4k = media.ratingKey4k;
|
||||||
|
let existsInPlex = false;
|
||||||
|
let preventSeasonSearch = false;
|
||||||
|
|
||||||
|
// Check each plex instance to see if the media still exists
|
||||||
|
// If found, we will assume the media exists and prevent removal
|
||||||
|
// We can use the cache we built when we fetched the series with mediaExistsInPlex
|
||||||
|
try {
|
||||||
|
let plexMedia: PlexMetadata | undefined;
|
||||||
|
|
||||||
|
if (ratingKey && !is4k) {
|
||||||
|
plexMedia = await this.plexClient?.getMetadata(ratingKey);
|
||||||
|
|
||||||
|
if (media.mediaType === 'tv') {
|
||||||
|
this.plexSeasonsCache[ratingKey] =
|
||||||
|
await this.plexClient?.getChildrenMetadata(ratingKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ratingKey4k && is4k) {
|
||||||
|
plexMedia = await this.plexClient?.getMetadata(ratingKey4k);
|
||||||
|
|
||||||
|
if (media.mediaType === 'tv') {
|
||||||
|
this.plexSeasonsCache[ratingKey4k] =
|
||||||
|
await this.plexClient?.getChildrenMetadata(ratingKey4k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plexMedia) {
|
||||||
|
existsInPlex = true;
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
if (!ex.message.includes('404')) {
|
||||||
|
existsInPlex = true;
|
||||||
|
preventSeasonSearch = true;
|
||||||
|
logger.debug(
|
||||||
|
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
|
||||||
|
media.mediaType === 'tv' ? 'show' : 'movie'
|
||||||
|
} [TMDB ID ${media.tmdbId}] from Plex.`,
|
||||||
|
{
|
||||||
|
errorMessage: ex.message,
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Here we check each season in plex for availability
|
||||||
|
// If the API returns an error other than a 404,
|
||||||
|
// we will have to prevent the season check from happening
|
||||||
|
if (media.mediaType === 'tv') {
|
||||||
|
const seasonsMap: Map<number, boolean> = new Map();
|
||||||
|
|
||||||
|
if (!preventSeasonSearch) {
|
||||||
|
const filteredSeasons = media.seasons.filter(
|
||||||
|
(season) =>
|
||||||
|
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
|
||||||
|
season[is4k ? 'status4k' : 'status'] ===
|
||||||
|
MediaStatus.PARTIALLY_AVAILABLE
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const season of filteredSeasons) {
|
||||||
|
const seasonExists = await this.seasonExistsInPlex(
|
||||||
|
media,
|
||||||
|
season,
|
||||||
|
is4k
|
||||||
|
);
|
||||||
|
|
||||||
|
if (seasonExists) {
|
||||||
|
seasonsMap.set(season.seasonNumber, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { existsInPlex, seasonsMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { existsInPlex };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seasonExistsInPlex(
|
||||||
|
media: Media,
|
||||||
|
season: Season,
|
||||||
|
is4k: boolean
|
||||||
|
): Promise<boolean> {
|
||||||
|
const ratingKey = media.ratingKey;
|
||||||
|
const ratingKey4k = media.ratingKey4k;
|
||||||
|
let seasonExistsInPlex = false;
|
||||||
|
|
||||||
|
// Check each plex instance to see if the season exists
|
||||||
|
let plexSeasons: PlexMetadata[] | undefined;
|
||||||
|
|
||||||
|
if (ratingKey && !is4k) {
|
||||||
|
plexSeasons = this.plexSeasonsCache[ratingKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ratingKey4k && is4k) {
|
||||||
|
plexSeasons = this.plexSeasonsCache[ratingKey4k];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasonIsAvailable = plexSeasons?.find(
|
||||||
|
(plexSeason) => plexSeason.index === season.seasonNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
if (seasonIsAvailable) {
|
||||||
|
seasonExistsInPlex = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return seasonExistsInPlex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const availabilitySync = new AvailabilitySync();
|
||||||
|
export default availabilitySync;
|
||||||
@@ -5,8 +5,11 @@ export type AvailableCacheIds =
|
|||||||
| 'radarr'
|
| 'radarr'
|
||||||
| 'sonarr'
|
| 'sonarr'
|
||||||
| 'rt'
|
| 'rt'
|
||||||
|
| 'imdb'
|
||||||
| 'github'
|
| 'github'
|
||||||
| 'plexguid';
|
| 'plexguid'
|
||||||
|
| 'plextv'
|
||||||
|
| 'plexwatchlist';
|
||||||
|
|
||||||
const DEFAULT_TTL = 300;
|
const DEFAULT_TTL = 300;
|
||||||
const DEFAULT_CHECK_PERIOD = 120;
|
const DEFAULT_CHECK_PERIOD = 120;
|
||||||
@@ -50,6 +53,10 @@ class CacheManager {
|
|||||||
stdTtl: 43200,
|
stdTtl: 43200,
|
||||||
checkPeriod: 60 * 30,
|
checkPeriod: 60 * 30,
|
||||||
}),
|
}),
|
||||||
|
imdb: new Cache('imdb', 'IMDB Radarr Proxy', {
|
||||||
|
stdTtl: 43200,
|
||||||
|
checkPeriod: 60 * 30,
|
||||||
|
}),
|
||||||
github: new Cache('github', 'GitHub API', {
|
github: new Cache('github', 'GitHub API', {
|
||||||
stdTtl: 21600,
|
stdTtl: 21600,
|
||||||
checkPeriod: 60 * 30,
|
checkPeriod: 60 * 30,
|
||||||
@@ -58,6 +65,11 @@ class CacheManager {
|
|||||||
stdTtl: 86400 * 7, // 1 week cache
|
stdTtl: 86400 * 7, // 1 week cache
|
||||||
checkPeriod: 60 * 30,
|
checkPeriod: 60 * 30,
|
||||||
}),
|
}),
|
||||||
|
plextv: new Cache('plextv', 'Plex TV', {
|
||||||
|
stdTtl: 86400 * 7, // 1 week cache
|
||||||
|
checkPeriod: 60,
|
||||||
|
}),
|
||||||
|
plexwatchlist: new Cache('plexwatchlist', 'Plex Watchlist'),
|
||||||
};
|
};
|
||||||
|
|
||||||
public getCache(id: AvailableCacheIds): Cache {
|
public getCache(id: AvailableCacheIds): Cache {
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
import RadarrAPI from '@server/api/servarr/radarr';
|
||||||
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
|
import { MediaType } from '@server/constants/media';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
import { uniqWith } from 'lodash';
|
import { uniqWith } from 'lodash';
|
||||||
import RadarrAPI from '../api/servarr/radarr';
|
|
||||||
import SonarrAPI from '../api/servarr/sonarr';
|
|
||||||
import { MediaType } from '../constants/media';
|
|
||||||
import logger from '../logger';
|
|
||||||
import { getSettings } from './settings';
|
|
||||||
|
|
||||||
|
interface EpisodeNumberResult {
|
||||||
|
seasonNumber: number;
|
||||||
|
episodeNumber: number;
|
||||||
|
absoluteEpisodeNumber: number;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
export interface DownloadingItem {
|
export interface DownloadingItem {
|
||||||
mediaType: MediaType;
|
mediaType: MediaType;
|
||||||
externalId: number;
|
externalId: number;
|
||||||
@@ -14,6 +20,7 @@ export interface DownloadingItem {
|
|||||||
timeLeft: string;
|
timeLeft: string;
|
||||||
estimatedCompletionTime: Date;
|
estimatedCompletionTime: Date;
|
||||||
title: string;
|
title: string;
|
||||||
|
episode?: EpisodeNumberResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
class DownloadTracker {
|
class DownloadTracker {
|
||||||
@@ -164,6 +171,7 @@ class DownloadTracker {
|
|||||||
status: item.status,
|
status: item.status,
|
||||||
timeLeft: item.timeleft,
|
timeLeft: item.timeleft,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
|
episode: item.episode,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (queueItems.length > 0) {
|
if (queueItems.length > 0) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import type { NotificationAgentEmail } from '@server/lib/settings';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
import Email from 'email-templates';
|
import Email from 'email-templates';
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
import type { NotificationAgentEmail } from '../settings';
|
|
||||||
import { getSettings } from '../settings';
|
|
||||||
import { openpgpEncrypt } from './openpgpEncrypt';
|
import { openpgpEncrypt } from './openpgpEncrypt';
|
||||||
|
|
||||||
class PreparedEmail extends Email {
|
class PreparedEmail extends Email {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import logger from '@server/logger';
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
import * as openpgp from 'openpgp';
|
import * as openpgp from 'openpgp';
|
||||||
import type { TransformCallback } from 'stream';
|
import type { TransformCallback } from 'stream';
|
||||||
import { Transform } from 'stream';
|
import { Transform } from 'stream';
|
||||||
import logger from '../../logger';
|
|
||||||
|
|
||||||
interface EncryptorOptions {
|
interface EncryptorOptions {
|
||||||
signingKey?: string;
|
signingKey?: string;
|
||||||
|
|||||||
266
server/lib/imageproxy.ts
Normal file
266
server/lib/imageproxy.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import logger from '@server/logger';
|
||||||
|
import axios from 'axios';
|
||||||
|
import rateLimit, { type rateLimitOptions } from 'axios-rate-limit';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { promises } from 'fs';
|
||||||
|
import path, { join } from 'path';
|
||||||
|
|
||||||
|
type ImageResponse = {
|
||||||
|
meta: {
|
||||||
|
revalidateAfter: number;
|
||||||
|
curRevalidate: number;
|
||||||
|
isStale: boolean;
|
||||||
|
etag: string;
|
||||||
|
extension: string;
|
||||||
|
cacheKey: string;
|
||||||
|
cacheMiss: boolean;
|
||||||
|
};
|
||||||
|
imageBuffer: Buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseCacheDirectory = process.env.CONFIG_DIRECTORY
|
||||||
|
? `${process.env.CONFIG_DIRECTORY}/cache/images`
|
||||||
|
: path.join(__dirname, '../../config/cache/images');
|
||||||
|
|
||||||
|
class ImageProxy {
|
||||||
|
public static async clearCache(key: string) {
|
||||||
|
let deletedImages = 0;
|
||||||
|
const cacheDirectory = path.join(baseCacheDirectory, key);
|
||||||
|
|
||||||
|
const files = await promises.readdir(cacheDirectory);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(cacheDirectory, file);
|
||||||
|
const stat = await promises.lstat(filePath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
const imageFiles = await promises.readdir(filePath);
|
||||||
|
|
||||||
|
for (const imageFile of imageFiles) {
|
||||||
|
const [, expireAtSt] = imageFile.split('.');
|
||||||
|
const expireAt = Number(expireAtSt);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (now > expireAt) {
|
||||||
|
await promises.rm(path.join(filePath, imageFile));
|
||||||
|
deletedImages += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Cleared ${deletedImages} stale image(s) from cache`, {
|
||||||
|
label: 'Image Cache',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getImageStats(
|
||||||
|
key: string
|
||||||
|
): Promise<{ size: number; imageCount: number }> {
|
||||||
|
const cacheDirectory = path.join(baseCacheDirectory, key);
|
||||||
|
|
||||||
|
const imageTotalSize = await ImageProxy.getDirectorySize(cacheDirectory);
|
||||||
|
const imageCount = await ImageProxy.getImageCount(cacheDirectory);
|
||||||
|
|
||||||
|
return {
|
||||||
|
size: imageTotalSize,
|
||||||
|
imageCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getDirectorySize(dir: string): Promise<number> {
|
||||||
|
const files = await promises.readdir(dir, {
|
||||||
|
withFileTypes: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const paths = files.map(async (file) => {
|
||||||
|
const path = join(dir, file.name);
|
||||||
|
|
||||||
|
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
|
||||||
|
|
||||||
|
if (file.isFile()) {
|
||||||
|
const { size } = await promises.stat(path);
|
||||||
|
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (await Promise.all(paths))
|
||||||
|
.flat(Infinity)
|
||||||
|
.reduce((i, size) => i + size, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getImageCount(dir: string) {
|
||||||
|
const files = await promises.readdir(dir);
|
||||||
|
|
||||||
|
return files.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private axios;
|
||||||
|
private cacheVersion;
|
||||||
|
private key;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
key: string,
|
||||||
|
baseUrl: string,
|
||||||
|
options: {
|
||||||
|
cacheVersion?: number;
|
||||||
|
rateLimitOptions?: rateLimitOptions;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
this.cacheVersion = options.cacheVersion ?? 1;
|
||||||
|
this.key = key;
|
||||||
|
this.axios = axios.create({
|
||||||
|
baseURL: baseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.rateLimitOptions) {
|
||||||
|
this.axios = rateLimit(this.axios, options.rateLimitOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getImage(path: string): Promise<ImageResponse> {
|
||||||
|
const cacheKey = this.getCacheKey(path);
|
||||||
|
|
||||||
|
const imageResponse = await this.get(cacheKey);
|
||||||
|
|
||||||
|
if (!imageResponse) {
|
||||||
|
const newImage = await this.set(path, cacheKey);
|
||||||
|
|
||||||
|
if (!newImage) {
|
||||||
|
throw new Error('Failed to load image');
|
||||||
|
}
|
||||||
|
|
||||||
|
return newImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the image is stale, we will revalidate it in the background.
|
||||||
|
if (imageResponse.meta.isStale) {
|
||||||
|
this.set(path, cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async get(cacheKey: string): Promise<ImageResponse | null> {
|
||||||
|
try {
|
||||||
|
const directory = join(this.getCacheDirectory(), cacheKey);
|
||||||
|
const files = await promises.readdir(directory);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const [maxAgeSt, expireAtSt, etag, extension] = file.split('.');
|
||||||
|
const buffer = await promises.readFile(join(directory, file));
|
||||||
|
const expireAt = Number(expireAtSt);
|
||||||
|
const maxAge = Number(maxAgeSt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: {
|
||||||
|
curRevalidate: maxAge,
|
||||||
|
revalidateAfter: maxAge * 1000 + now,
|
||||||
|
isStale: now > expireAt,
|
||||||
|
etag,
|
||||||
|
extension,
|
||||||
|
cacheKey,
|
||||||
|
cacheMiss: false,
|
||||||
|
},
|
||||||
|
imageBuffer: buffer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// No files. Treat as empty cache.
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async set(
|
||||||
|
path: string,
|
||||||
|
cacheKey: string
|
||||||
|
): Promise<ImageResponse | null> {
|
||||||
|
try {
|
||||||
|
const directory = join(this.getCacheDirectory(), cacheKey);
|
||||||
|
const response = await this.axios.get(path, {
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = Buffer.from(response.data, 'binary');
|
||||||
|
const extension = path.split('.').pop() ?? '';
|
||||||
|
const maxAge = Number(
|
||||||
|
(response.headers['cache-control'] ?? '0').split('=')[1]
|
||||||
|
);
|
||||||
|
const expireAt = Date.now() + maxAge * 1000;
|
||||||
|
const etag = (response.headers.etag ?? '').replace(/"/g, '');
|
||||||
|
|
||||||
|
await this.writeToCacheDir(
|
||||||
|
directory,
|
||||||
|
extension,
|
||||||
|
maxAge,
|
||||||
|
expireAt,
|
||||||
|
buffer,
|
||||||
|
etag
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: {
|
||||||
|
curRevalidate: maxAge,
|
||||||
|
revalidateAfter: expireAt,
|
||||||
|
isStale: false,
|
||||||
|
etag,
|
||||||
|
extension,
|
||||||
|
cacheKey,
|
||||||
|
cacheMiss: true,
|
||||||
|
},
|
||||||
|
imageBuffer: buffer,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('Something went wrong caching image.', {
|
||||||
|
label: 'Image Cache',
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeToCacheDir(
|
||||||
|
dir: string,
|
||||||
|
extension: string,
|
||||||
|
maxAge: number,
|
||||||
|
expireAt: number,
|
||||||
|
buffer: Buffer,
|
||||||
|
etag: string
|
||||||
|
) {
|
||||||
|
const filename = join(dir, `${maxAge}.${expireAt}.${etag}.${extension}`);
|
||||||
|
|
||||||
|
await promises.rm(dir, { force: true, recursive: true }).catch(() => {
|
||||||
|
// do nothing
|
||||||
|
});
|
||||||
|
|
||||||
|
await promises.mkdir(dir, { recursive: true });
|
||||||
|
await promises.writeFile(filename, buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCacheKey(path: string) {
|
||||||
|
return this.getHash([this.key, this.cacheVersion, path]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getHash(items: (string | number | Buffer)[]) {
|
||||||
|
const hash = createHash('sha256');
|
||||||
|
for (const item of items) {
|
||||||
|
if (typeof item === 'number') hash.update(String(item));
|
||||||
|
else {
|
||||||
|
hash.update(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// See https://en.wikipedia.org/wiki/Base64#Filenames
|
||||||
|
return hash.digest('base64').replace(/\//g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCacheDirectory() {
|
||||||
|
return path.join(baseCacheDirectory, this.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImageProxy;
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
|
import type Issue from '@server/entity/Issue';
|
||||||
|
import type IssueComment from '@server/entity/IssueComment';
|
||||||
|
import type Media from '@server/entity/Media';
|
||||||
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
|
import type { User } from '@server/entity/User';
|
||||||
|
import type { NotificationAgentConfig } from '@server/lib/settings';
|
||||||
import type { Notification } from '..';
|
import type { Notification } from '..';
|
||||||
import type Issue from '../../../entity/Issue';
|
|
||||||
import type IssueComment from '../../../entity/IssueComment';
|
|
||||||
import type Media from '../../../entity/Media';
|
|
||||||
import type { MediaRequest } from '../../../entity/MediaRequest';
|
|
||||||
import type { User } from '../../../entity/User';
|
|
||||||
import type { NotificationAgentConfig } from '../../settings';
|
|
||||||
|
|
||||||
export interface NotificationPayload {
|
export interface NotificationPayload {
|
||||||
event?: string;
|
event?: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
|
notifySystem: boolean;
|
||||||
notifyAdmin: boolean;
|
notifyAdmin: boolean;
|
||||||
notifyUser?: User;
|
notifyUser?: User;
|
||||||
media?: Media;
|
media?: Media;
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
|
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import { User } from '@server/entity/User';
|
||||||
|
import type { NotificationAgentDiscord } from '@server/lib/settings';
|
||||||
|
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import {
|
import {
|
||||||
hasNotificationType,
|
hasNotificationType,
|
||||||
Notification,
|
Notification,
|
||||||
shouldSendAdminNotification,
|
shouldSendAdminNotification,
|
||||||
} from '..';
|
} from '..';
|
||||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
|
||||||
import { getRepository } from '../../../datasource';
|
|
||||||
import { User } from '../../../entity/User';
|
|
||||||
import logger from '../../../logger';
|
|
||||||
import type { NotificationAgentDiscord } from '../../settings';
|
|
||||||
import { getSettings, NotificationAgentKey } from '../../settings';
|
|
||||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||||
import { BaseAgent } from './agent';
|
import { BaseAgent } from './agent';
|
||||||
|
|
||||||
@@ -243,7 +243,10 @@ class DiscordAgent
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const settings = this.getSettings();
|
const settings = this.getSettings();
|
||||||
|
|
||||||
if (!hasNotificationType(type, settings.types ?? 0)) {
|
if (
|
||||||
|
!payload.notifySystem ||
|
||||||
|
!hasNotificationType(type, settings.types ?? 0)
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
|
import { IssueType, IssueTypeName } from '@server/constants/issue';
|
||||||
|
import { MediaType } from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import { User } from '@server/entity/User';
|
||||||
|
import PreparedEmail from '@server/lib/email';
|
||||||
|
import type { NotificationAgentEmail } from '@server/lib/settings';
|
||||||
|
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
import type { EmailOptions } from 'email-templates';
|
import type { EmailOptions } from 'email-templates';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { Notification, shouldSendAdminNotification } from '..';
|
import { Notification, shouldSendAdminNotification } from '..';
|
||||||
import { IssueType, IssueTypeName } from '../../../constants/issue';
|
|
||||||
import { MediaType } from '../../../constants/media';
|
|
||||||
import { getRepository } from '../../../datasource';
|
|
||||||
import { User } from '../../../entity/User';
|
|
||||||
import logger from '../../../logger';
|
|
||||||
import PreparedEmail from '../../email';
|
|
||||||
import type { NotificationAgentEmail } from '../../settings';
|
|
||||||
import { getSettings, NotificationAgentKey } from '../../settings';
|
|
||||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||||
import { BaseAgent } from './agent';
|
import { BaseAgent } from './agent';
|
||||||
|
|
||||||
@@ -81,6 +81,11 @@ class EmailAgent
|
|||||||
is4k ? 'in 4K ' : ''
|
is4k ? 'in 4K ' : ''
|
||||||
}is pending approval:`;
|
}is pending approval:`;
|
||||||
break;
|
break;
|
||||||
|
case Notification.MEDIA_AUTO_REQUESTED:
|
||||||
|
body = `A new request for the following ${mediaType} ${
|
||||||
|
is4k ? 'in 4K ' : ''
|
||||||
|
}was automatically submitted:`;
|
||||||
|
break;
|
||||||
case Notification.MEDIA_APPROVED:
|
case Notification.MEDIA_APPROVED:
|
||||||
body = `Your request for the following ${mediaType} ${
|
body = `Your request for the following ${mediaType} ${
|
||||||
is4k ? 'in 4K ' : ''
|
is4k ? 'in 4K ' : ''
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
|
||||||
|
import type { NotificationAgentGotify } from '@server/lib/settings';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { hasNotificationType, Notification } from '..';
|
import { hasNotificationType, Notification } from '..';
|
||||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
|
||||||
import logger from '../../../logger';
|
|
||||||
import type { NotificationAgentGotify } from '../../settings';
|
|
||||||
import { getSettings } from '../../settings';
|
|
||||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||||
import { BaseAgent } from './agent';
|
import { BaseAgent } from './agent';
|
||||||
|
|
||||||
@@ -117,7 +117,10 @@ class GotifyAgent
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const settings = this.getSettings();
|
const settings = this.getSettings();
|
||||||
|
|
||||||
if (!hasNotificationType(type, settings.types ?? 0)) {
|
if (
|
||||||
|
!payload.notifySystem ||
|
||||||
|
!hasNotificationType(type, settings.types ?? 0)
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import { IssueStatus, IssueType } from '@server/constants/issue';
|
||||||
|
import { MediaStatus } from '@server/constants/media';
|
||||||
|
import type { NotificationAgentLunaSea } from '@server/lib/settings';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { hasNotificationType, Notification } from '..';
|
import { hasNotificationType, Notification } from '..';
|
||||||
import { IssueStatus, IssueType } from '../../../constants/issue';
|
|
||||||
import { MediaStatus } from '../../../constants/media';
|
|
||||||
import logger from '../../../logger';
|
|
||||||
import type { NotificationAgentLunaSea } from '../../settings';
|
|
||||||
import { getSettings } from '../../settings';
|
|
||||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||||
import { BaseAgent } from './agent';
|
import { BaseAgent } from './agent';
|
||||||
|
|
||||||
@@ -87,7 +87,10 @@ class LunaSeaAgent
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const settings = this.getSettings();
|
const settings = this.getSettings();
|
||||||
|
|
||||||
if (!hasNotificationType(type, settings.types ?? 0)) {
|
if (
|
||||||
|
!payload.notifySystem ||
|
||||||
|
!hasNotificationType(type, settings.types ?? 0)
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
|
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
|
||||||
|
import { MediaStatus } from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import { User } from '@server/entity/User';
|
||||||
|
import type { NotificationAgentPushbullet } from '@server/lib/settings';
|
||||||
|
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import {
|
import {
|
||||||
hasNotificationType,
|
hasNotificationType,
|
||||||
Notification,
|
Notification,
|
||||||
shouldSendAdminNotification,
|
shouldSendAdminNotification,
|
||||||
} from '..';
|
} from '..';
|
||||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
|
||||||
import { getRepository } from '../../../datasource';
|
|
||||||
import { User } from '../../../entity/User';
|
|
||||||
import logger from '../../../logger';
|
|
||||||
import type { NotificationAgentPushbullet } from '../../settings';
|
|
||||||
import { getSettings, NotificationAgentKey } from '../../settings';
|
|
||||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||||
import { BaseAgent } from './agent';
|
import { BaseAgent } from './agent';
|
||||||
|
|
||||||
@@ -52,6 +53,12 @@ class PushbulletAgent
|
|||||||
|
|
||||||
let status = '';
|
let status = '';
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case Notification.MEDIA_AUTO_REQUESTED:
|
||||||
|
status =
|
||||||
|
payload.media?.status === MediaStatus.PENDING
|
||||||
|
? 'Pending Approval'
|
||||||
|
: 'Processing';
|
||||||
|
break;
|
||||||
case Notification.MEDIA_PENDING:
|
case Notification.MEDIA_PENDING:
|
||||||
status = 'Pending Approval';
|
status = 'Pending Approval';
|
||||||
break;
|
break;
|
||||||
@@ -104,6 +111,7 @@ class PushbulletAgent
|
|||||||
|
|
||||||
// Send system notification
|
// Send system notification
|
||||||
if (
|
if (
|
||||||
|
payload.notifySystem &&
|
||||||
hasNotificationType(type, settings.types ?? 0) &&
|
hasNotificationType(type, settings.types ?? 0) &&
|
||||||
settings.enabled &&
|
settings.enabled &&
|
||||||
settings.options.accessToken
|
settings.options.accessToken
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
|
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
|
||||||
|
import { MediaStatus } from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import { User } from '@server/entity/User';
|
||||||
|
import type { NotificationAgentPushover } from '@server/lib/settings';
|
||||||
|
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import {
|
import {
|
||||||
hasNotificationType,
|
hasNotificationType,
|
||||||
Notification,
|
Notification,
|
||||||
shouldSendAdminNotification,
|
shouldSendAdminNotification,
|
||||||
} from '..';
|
} from '..';
|
||||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
|
||||||
import { getRepository } from '../../../datasource';
|
|
||||||
import { User } from '../../../entity/User';
|
|
||||||
import logger from '../../../logger';
|
|
||||||
import type { NotificationAgentPushover } from '../../settings';
|
|
||||||
import { getSettings, NotificationAgentKey } from '../../settings';
|
|
||||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||||
import { BaseAgent } from './agent';
|
import { BaseAgent } from './agent';
|
||||||
|
|
||||||
interface PushoverPayload {
|
interface PushoverImagePayload {
|
||||||
|
attachment_base64: string;
|
||||||
|
attachment_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PushoverPayload extends PushoverImagePayload {
|
||||||
token: string;
|
token: string;
|
||||||
user: string;
|
user: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -42,10 +48,36 @@ class PushoverAgent
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getNotificationPayload(
|
private async getImagePayload(
|
||||||
|
imageUrl: string
|
||||||
|
): Promise<Partial<PushoverImagePayload>> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(imageUrl, {
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
});
|
||||||
|
const base64 = Buffer.from(response.data, 'binary').toString('base64');
|
||||||
|
const contentType = (
|
||||||
|
response.headers['Content-Type'] || response.headers['content-type']
|
||||||
|
)?.toString();
|
||||||
|
|
||||||
|
return {
|
||||||
|
attachment_base64: base64,
|
||||||
|
attachment_type: contentType,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Error getting image payload', {
|
||||||
|
label: 'Notifications',
|
||||||
|
errorMessage: e.message,
|
||||||
|
response: e.response?.data,
|
||||||
|
});
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getNotificationPayload(
|
||||||
type: Notification,
|
type: Notification,
|
||||||
payload: NotificationPayload
|
payload: NotificationPayload
|
||||||
): Partial<PushoverPayload> {
|
): Promise<Partial<PushoverPayload>> {
|
||||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||||
|
|
||||||
const title = payload.event ?? payload.subject;
|
const title = payload.event ?? payload.subject;
|
||||||
@@ -61,6 +93,12 @@ class PushoverAgent
|
|||||||
|
|
||||||
let status = '';
|
let status = '';
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case Notification.MEDIA_AUTO_REQUESTED:
|
||||||
|
status =
|
||||||
|
payload.media?.status === MediaStatus.PENDING
|
||||||
|
? 'Pending Approval'
|
||||||
|
: 'Processing';
|
||||||
|
break;
|
||||||
case Notification.MEDIA_PENDING:
|
case Notification.MEDIA_PENDING:
|
||||||
status = 'Pending Approval';
|
status = 'Pending Approval';
|
||||||
break;
|
break;
|
||||||
@@ -115,6 +153,16 @@ class PushoverAgent
|
|||||||
? `View ${payload.issue ? 'Issue' : 'Media'} in ${applicationTitle}`
|
? `View ${payload.issue ? 'Issue' : 'Media'} in ${applicationTitle}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
let attachment_base64;
|
||||||
|
let attachment_type;
|
||||||
|
if (payload.image) {
|
||||||
|
const imagePayload = await this.getImagePayload(payload.image);
|
||||||
|
if (imagePayload.attachment_base64 && imagePayload.attachment_type) {
|
||||||
|
attachment_base64 = imagePayload.attachment_base64;
|
||||||
|
attachment_type = imagePayload.attachment_type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
@@ -122,6 +170,8 @@ class PushoverAgent
|
|||||||
url_title,
|
url_title,
|
||||||
priority,
|
priority,
|
||||||
html: 1,
|
html: 1,
|
||||||
|
attachment_base64,
|
||||||
|
attachment_type,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,10 +181,14 @@ class PushoverAgent
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const settings = this.getSettings();
|
const settings = this.getSettings();
|
||||||
const endpoint = 'https://api.pushover.net/1/messages.json';
|
const endpoint = 'https://api.pushover.net/1/messages.json';
|
||||||
const notificationPayload = this.getNotificationPayload(type, payload);
|
const notificationPayload = await this.getNotificationPayload(
|
||||||
|
type,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
// Send system notification
|
// Send system notification
|
||||||
if (
|
if (
|
||||||
|
payload.notifySystem &&
|
||||||
hasNotificationType(type, settings.types ?? 0) &&
|
hasNotificationType(type, settings.types ?? 0) &&
|
||||||
settings.enabled &&
|
settings.enabled &&
|
||||||
settings.options.accessToken &&
|
settings.options.accessToken &&
|
||||||
@@ -151,6 +205,7 @@ class PushoverAgent
|
|||||||
...notificationPayload,
|
...notificationPayload,
|
||||||
token: settings.options.accessToken,
|
token: settings.options.accessToken,
|
||||||
user: settings.options.userToken,
|
user: settings.options.userToken,
|
||||||
|
sound: settings.options.sound,
|
||||||
} as PushoverPayload);
|
} as PushoverPayload);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error sending Pushover notification', {
|
logger.error('Error sending Pushover notification', {
|
||||||
@@ -190,6 +245,7 @@ class PushoverAgent
|
|||||||
...notificationPayload,
|
...notificationPayload,
|
||||||
token: payload.notifyUser.settings.pushoverApplicationToken,
|
token: payload.notifyUser.settings.pushoverApplicationToken,
|
||||||
user: payload.notifyUser.settings.pushoverUserKey,
|
user: payload.notifyUser.settings.pushoverUserKey,
|
||||||
|
sound: payload.notifyUser.settings.pushoverSound,
|
||||||
} as PushoverPayload);
|
} as PushoverPayload);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error sending Pushover notification', {
|
logger.error('Error sending Pushover notification', {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
|
||||||
|
import type { NotificationAgentSlack } from '@server/lib/settings';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { hasNotificationType, Notification } from '..';
|
import { hasNotificationType, Notification } from '..';
|
||||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
|
||||||
import logger from '../../../logger';
|
|
||||||
import type { NotificationAgentSlack } from '../../settings';
|
|
||||||
import { getSettings } from '../../settings';
|
|
||||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||||
import { BaseAgent } from './agent';
|
import { BaseAgent } from './agent';
|
||||||
|
|
||||||
@@ -225,7 +225,10 @@ class SlackAgent
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const settings = this.getSettings();
|
const settings = this.getSettings();
|
||||||
|
|
||||||
if (!hasNotificationType(type, settings.types ?? 0)) {
|
if (
|
||||||
|
!payload.notifySystem ||
|
||||||
|
!hasNotificationType(type, settings.types ?? 0)
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
|
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
|
||||||
|
import { MediaStatus } from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import { User } from '@server/entity/User';
|
||||||
|
import type { NotificationAgentTelegram } from '@server/lib/settings';
|
||||||
|
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import {
|
import {
|
||||||
hasNotificationType,
|
hasNotificationType,
|
||||||
Notification,
|
Notification,
|
||||||
shouldSendAdminNotification,
|
shouldSendAdminNotification,
|
||||||
} from '..';
|
} from '..';
|
||||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
|
||||||
import { getRepository } from '../../../datasource';
|
|
||||||
import { User } from '../../../entity/User';
|
|
||||||
import logger from '../../../logger';
|
|
||||||
import type { NotificationAgentTelegram } from '../../settings';
|
|
||||||
import { getSettings, NotificationAgentKey } from '../../settings';
|
|
||||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||||
import { BaseAgent } from './agent';
|
import { BaseAgent } from './agent';
|
||||||
|
|
||||||
@@ -79,6 +80,12 @@ class TelegramAgent
|
|||||||
|
|
||||||
let status = '';
|
let status = '';
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case Notification.MEDIA_AUTO_REQUESTED:
|
||||||
|
status =
|
||||||
|
payload.media?.status === MediaStatus.PENDING
|
||||||
|
? 'Pending Approval'
|
||||||
|
: 'Processing';
|
||||||
|
break;
|
||||||
case Notification.MEDIA_PENDING:
|
case Notification.MEDIA_PENDING:
|
||||||
status = 'Pending Approval';
|
status = 'Pending Approval';
|
||||||
break;
|
break;
|
||||||
@@ -157,6 +164,7 @@ class TelegramAgent
|
|||||||
|
|
||||||
// Send system notification
|
// Send system notification
|
||||||
if (
|
if (
|
||||||
|
payload.notifySystem &&
|
||||||
hasNotificationType(type, settings.types ?? 0) &&
|
hasNotificationType(type, settings.types ?? 0) &&
|
||||||
settings.options.chatId
|
settings.options.chatId
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
|
import { IssueStatus, IssueType } from '@server/constants/issue';
|
||||||
|
import { MediaStatus } from '@server/constants/media';
|
||||||
|
import type { NotificationAgentWebhook } from '@server/lib/settings';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { get } from 'lodash';
|
import { get } from 'lodash';
|
||||||
import { hasNotificationType, Notification } from '..';
|
import { hasNotificationType, Notification } from '..';
|
||||||
import { IssueStatus, IssueType } from '../../../constants/issue';
|
|
||||||
import { MediaStatus } from '../../../constants/media';
|
|
||||||
import logger from '../../../logger';
|
|
||||||
import type { NotificationAgentWebhook } from '../../settings';
|
|
||||||
import { getSettings } from '../../settings';
|
|
||||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||||
import { BaseAgent } from './agent';
|
import { BaseAgent } from './agent';
|
||||||
|
|
||||||
@@ -164,7 +164,10 @@ class WebhookAgent
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const settings = this.getSettings();
|
const settings = this.getSettings();
|
||||||
|
|
||||||
if (!hasNotificationType(type, settings.types ?? 0)) {
|
if (
|
||||||
|
!payload.notifySystem ||
|
||||||
|
!hasNotificationType(type, settings.types ?? 0)
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
|
import { IssueType, IssueTypeName } from '@server/constants/issue';
|
||||||
|
import { MediaType } from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import { User } from '@server/entity/User';
|
||||||
|
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
|
||||||
|
import type { NotificationAgentConfig } from '@server/lib/settings';
|
||||||
|
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
import webpush from 'web-push';
|
import webpush from 'web-push';
|
||||||
import { Notification, shouldSendAdminNotification } from '..';
|
import { Notification, shouldSendAdminNotification } from '..';
|
||||||
import { IssueType, IssueTypeName } from '../../../constants/issue';
|
|
||||||
import { MediaType } from '../../../constants/media';
|
|
||||||
import { getRepository } from '../../../datasource';
|
|
||||||
import { User } from '../../../entity/User';
|
|
||||||
import { UserPushSubscription } from '../../../entity/UserPushSubscription';
|
|
||||||
import logger from '../../../logger';
|
|
||||||
import type { NotificationAgentConfig } from '../../settings';
|
|
||||||
import { getSettings, NotificationAgentKey } from '../../settings';
|
|
||||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||||
import { BaseAgent } from './agent';
|
import { BaseAgent } from './agent';
|
||||||
|
|
||||||
@@ -57,6 +57,11 @@ class WebPushAgent
|
|||||||
case Notification.TEST_NOTIFICATION:
|
case Notification.TEST_NOTIFICATION:
|
||||||
message = payload.message;
|
message = payload.message;
|
||||||
break;
|
break;
|
||||||
|
case Notification.MEDIA_AUTO_REQUESTED:
|
||||||
|
message = `Automatically submitted a new ${
|
||||||
|
is4k ? '4K ' : ''
|
||||||
|
}${mediaType} request.`;
|
||||||
|
break;
|
||||||
case Notification.MEDIA_APPROVED:
|
case Notification.MEDIA_APPROVED:
|
||||||
message = `Your ${
|
message = `Your ${
|
||||||
is4k ? '4K ' : ''
|
is4k ? '4K ' : ''
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { User } from '../../entity/User';
|
import type { User } from '@server/entity/User';
|
||||||
import logger from '../../logger';
|
import { Permission } from '@server/lib/permissions';
|
||||||
import { Permission } from '../permissions';
|
import logger from '@server/logger';
|
||||||
import type { NotificationAgent, NotificationPayload } from './agents/agent';
|
import type { NotificationAgent, NotificationPayload } from './agents/agent';
|
||||||
|
|
||||||
export enum Notification {
|
export enum Notification {
|
||||||
@@ -16,6 +16,7 @@ export enum Notification {
|
|||||||
ISSUE_COMMENT = 512,
|
ISSUE_COMMENT = 512,
|
||||||
ISSUE_RESOLVED = 1024,
|
ISSUE_RESOLVED = 1024,
|
||||||
ISSUE_REOPENED = 2048,
|
ISSUE_REOPENED = 2048,
|
||||||
|
MEDIA_AUTO_REQUESTED = 4096,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const hasNotificationType = (
|
export const hasNotificationType = (
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ export enum Permission {
|
|||||||
MANAGE_ISSUES = 1048576,
|
MANAGE_ISSUES = 1048576,
|
||||||
VIEW_ISSUES = 2097152,
|
VIEW_ISSUES = 2097152,
|
||||||
CREATE_ISSUES = 4194304,
|
CREATE_ISSUES = 4194304,
|
||||||
|
AUTO_REQUEST = 8388608,
|
||||||
|
AUTO_REQUEST_MOVIE = 16777216,
|
||||||
|
AUTO_REQUEST_TV = 33554432,
|
||||||
RECENT_VIEW = 67108864,
|
RECENT_VIEW = 67108864,
|
||||||
|
WATCHLIST_VIEW = 134217728,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PermissionCheckOptions {
|
export interface PermissionCheckOptions {
|
||||||
|
|||||||
37
server/lib/refreshToken.ts
Normal file
37
server/lib/refreshToken.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import PlexTvAPI from '@server/api/plextv';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import { User } from '@server/entity/User';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
|
||||||
|
class RefreshToken {
|
||||||
|
public async run() {
|
||||||
|
const userRepository = getRepository(User);
|
||||||
|
|
||||||
|
const users = await userRepository
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.addSelect('user.plexToken')
|
||||||
|
.where("user.plexToken != ''")
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
await this.refreshUserToken(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshUserToken(user: User) {
|
||||||
|
if (!user.plexToken) {
|
||||||
|
logger.warn('Skipping user refresh token for user without plex token', {
|
||||||
|
label: 'Plex Refresh Token',
|
||||||
|
user: user.displayName,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plexTvApi = new PlexTvAPI(user.plexToken);
|
||||||
|
plexTvApi.pingToken();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshToken = new RefreshToken();
|
||||||
|
|
||||||
|
export default refreshToken;
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
|
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import Media from '@server/entity/Media';
|
||||||
|
import Season from '@server/entity/Season';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import AsyncLock from '@server/utils/asyncLock';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import TheMovieDb from '../../api/themoviedb';
|
|
||||||
import { MediaStatus, MediaType } from '../../constants/media';
|
|
||||||
import { getRepository } from '../../datasource';
|
|
||||||
import Media from '../../entity/Media';
|
|
||||||
import Season from '../../entity/Season';
|
|
||||||
import logger from '../../logger';
|
|
||||||
import AsyncLock from '../../utils/asyncLock';
|
|
||||||
import { getSettings } from '../settings';
|
|
||||||
|
|
||||||
// Default scan rates (can be overidden)
|
// Default scan rates (can be overidden)
|
||||||
const BUNDLE_SIZE = 20;
|
const BUNDLE_SIZE = 20;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user