Compare commits
257 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c65c08aac | ||
|
|
f31d2c4729 | ||
|
|
83b008c839 | ||
|
|
fd4204f0f5 | ||
|
|
8a5943f206 | ||
|
|
68c7b3650e | ||
|
|
2816c66300 | ||
|
|
01de972a8f | ||
|
|
a761b7dd35 | ||
|
|
f32a9c20ef | ||
|
|
2dc0009675 | ||
|
|
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 | ||
|
|
3ad7faaf9b | ||
|
|
400639a94e | ||
|
|
c93467b3ac | ||
|
|
ef3d428076 | ||
|
|
2c76b17919 | ||
|
|
d954328911 | ||
|
|
e98d93db1a | ||
|
|
ba84a32c0d | ||
|
|
3e43586acc | ||
|
|
7040da1334 | ||
|
|
9d10e6a88c | ||
|
|
8942eb8b7c | ||
|
|
fe5e7cbf4b | ||
|
|
45ce57687a | ||
|
|
51b05cd8fb | ||
|
|
374c78c989 | ||
|
|
507693881b | ||
|
|
4b43747bcc | ||
|
|
714f244d6a | ||
|
|
f4a22dc437 | ||
|
|
acfaf7eb45 | ||
|
|
b83d8c8f5c | ||
|
|
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 | ||
|
|
dd5d2698a6 | ||
|
|
c5e59616fa | ||
|
|
76a7ceb758 | ||
|
|
9688acaa87 | ||
|
|
15e246929b | ||
|
|
07ec3efbca | ||
|
|
64aab6dd82 | ||
|
|
144bb84bdc | ||
|
|
76260f9b22 | ||
|
|
500cd1f872 | ||
|
|
fc79fdabf4 | ||
|
|
7dd683c3d8 | ||
|
|
bfe56c3470 | ||
|
|
1dfa9431a9 | ||
|
|
f5c2fc1c20 | ||
|
|
21d20fdfd6 | ||
|
|
cf96db90ad | ||
|
|
430b1ab871 | ||
|
|
7404d68143 | ||
|
|
4a5421a9ba | ||
|
|
24b9c00b2a | ||
|
|
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 | ||
|
|
7943e0c339 | ||
|
|
6ce0aa5b10 | ||
|
|
a0301e2d83 | ||
|
|
9021696cf0 | ||
|
|
9bc1f89777 | ||
|
|
a12697b061 | ||
|
|
5247f14968 | ||
|
|
fd0ff4bd5f | ||
|
|
16545eec22 | ||
|
|
36d17fed6e | ||
|
|
ac34328074 | ||
|
|
91e0928aa0 | ||
|
|
f836cadd23 | ||
|
|
f4910a1483 | ||
|
|
103c4ca49c | ||
|
|
c143c0b8d2 | ||
|
|
e5d8c93ab8 | ||
|
|
72d7a3477f | ||
|
|
808fabba9a | ||
|
|
7a5fab35ff | ||
|
|
17ac5069e5 | ||
|
|
cfab63c0ca | ||
|
|
0fa84eae8d | ||
|
|
821bb79d83 | ||
|
|
233035dbd7 | ||
|
|
114943ae2c | ||
|
|
a6f7b19693 | ||
|
|
3db3044210 | ||
|
|
1fcfe93b58 | ||
|
|
6cb456cb69 | ||
|
|
e939dc678e | ||
|
|
f3e56da3b7 | ||
|
|
70dc4c4b3b | ||
|
|
6428b8d419 | ||
|
|
004e1bb17e | ||
|
|
ebd22ffcea | ||
|
|
22ec058431 | ||
|
|
db898db9f2 | ||
|
|
b33956e6b8 | ||
|
|
f5864b49de | ||
|
|
25eb765f9b | ||
|
|
9da8461225 | ||
|
|
aed1409f29 | ||
|
|
575da306b0 | ||
|
|
6cd0c9b2c8 | ||
|
|
a6c1f3f7ce | ||
|
|
eb5248d8d1 | ||
|
|
db05172d8b | ||
|
|
90095bb185 | ||
|
|
14519ef555 | ||
|
|
1054b4e2d7 | ||
|
|
e4039d09c0 | ||
|
|
29be659512 | ||
|
|
475314c87b | ||
|
|
1d00229a48 | ||
|
|
b2878390b4 | ||
|
|
5125abdbf0 | ||
|
|
724f8bb538 | ||
|
|
b75fc7b238 |
@@ -665,6 +665,222 @@
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "sambartik",
|
||||
"name": "Samuel Bartík",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/63553146?v=4",
|
||||
"profile": "https://github.com/sambartik",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "frank-cywong",
|
||||
"name": "Chun Yeung Wong",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/90653148?v=4",
|
||||
"profile": "https://github.com/frank-cywong",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "TheMeanCanEHdian",
|
||||
"name": "TheMeanCanEHdian",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/16025103?v=4",
|
||||
"profile": "https://github.com/TheMeanCanEHdian",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Gylesie",
|
||||
"name": "Gylesie",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/86306812?v=4",
|
||||
"profile": "https://github.com/Gylesie",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Fhd-pro",
|
||||
"name": "Fhd-pro",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/82862079?v=4",
|
||||
"profile": "https://github.com/Fhd-pro",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "PovilasID",
|
||||
"name": "PovilasID",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/396243?v=4",
|
||||
"profile": "https://github.com/PovilasID",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "byakurau",
|
||||
"name": "byakurau",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1811683?v=4",
|
||||
"profile": "https://github.com/byakurau",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "miknii",
|
||||
"name": "miknii",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/109232569?v=4",
|
||||
"profile": "https://github.com/miknii",
|
||||
"contributions": [
|
||||
"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"
|
||||
]
|
||||
}
|
||||
],
|
||||
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
||||
@@ -673,5 +889,7 @@
|
||||
"projectOwner": "sct",
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"skipCi": true
|
||||
"skipCi": false,
|
||||
"commitConvention": "angular",
|
||||
"commitType": "docs"
|
||||
}
|
||||
|
||||
@@ -26,3 +26,4 @@ public/os_logo_filled.png
|
||||
public/preview.jpg
|
||||
snap
|
||||
stylelint.config.js
|
||||
cypress
|
||||
|
||||
15
.eslintrc.js
15
.eslintrc.js
@@ -7,6 +7,7 @@ module.exports = {
|
||||
'plugin:jsx-a11y/recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:react/jsx-runtime',
|
||||
'prettier',
|
||||
],
|
||||
parserOptions: {
|
||||
@@ -26,11 +27,21 @@ module.exports = {
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'prettier/prettier': ['error', { endOfLine: 'auto' }],
|
||||
'formatjs/no-offset': 'error',
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['error'],
|
||||
'@typescript-eslint/array-type': ['error', { default: 'array' }],
|
||||
'jsx-a11y/no-onchange': 'off',
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{
|
||||
prefer: 'type-imports',
|
||||
},
|
||||
],
|
||||
'no-relative-import-paths/no-relative-import-paths': [
|
||||
'error',
|
||||
{ allowSameFolder: true },
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
@@ -40,7 +51,7 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
],
|
||||
plugins: ['jsx-a11y', 'prettier', 'react-hooks', 'formatjs'],
|
||||
plugins: ['jsx-a11y', 'react-hooks', 'formatjs', 'no-relative-import-paths'],
|
||||
settings: {
|
||||
react: {
|
||||
pragma: 'React',
|
||||
|
||||
10
.github/CODEOWNERS
vendored
10
.github/CODEOWNERS
vendored
@@ -1,10 +1,10 @@
|
||||
# Global code ownership
|
||||
* @sct @TheCatLady @danshilm
|
||||
* @sct @TheCatLady @danshilm @OwsleyJr
|
||||
|
||||
# Documentation
|
||||
/.all-contributorsrc @TheCatLady @samwiseg0 @danshilm
|
||||
/*.md @TheCatLady @samwiseg0 @danshilm
|
||||
/docs/ @TheCatLady @samwiseg0 @danshilm
|
||||
/.all-contributorsrc @TheCatLady @samwiseg0 @danshilm @OwsleyJr
|
||||
/*.md @TheCatLady @samwiseg0 @danshilm @OwsleyJr
|
||||
/docs/ @TheCatLady @samwiseg0 @danshilm @OwsleyJr
|
||||
|
||||
# Snap-related files
|
||||
/.github/workflows/snap.yaml @samwiseg0
|
||||
@@ -12,4 +12,4 @@
|
||||
|
||||
# i18n locale files
|
||||
/src/i18n/locale/ @sct @TheCatLady
|
||||
/src/i18n/locale/en.json @sct @TheCatLady @danshilm
|
||||
/src/i18n/locale/en.json @sct @TheCatLady @danshilm @OwsleyJr
|
||||
|
||||
14
.github/dependabot.yml
vendored
14
.github/dependabot.yml
vendored
@@ -1,14 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: daily
|
||||
time: '20:00'
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: github-actions
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: daily
|
||||
time: '20:00'
|
||||
open-pull-requests-limit: 10
|
||||
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
|
||||
24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
@@ -13,16 +13,18 @@ jobs:
|
||||
name: Lint & Test Build
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-20.04
|
||||
container: node:16.14-alpine
|
||||
container: node:16.17-alpine
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Install dependencies
|
||||
env:
|
||||
HUSKY_SKIP_INSTALL: 1
|
||||
HUSKY: 0
|
||||
run: yarn
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
- name: Formatting
|
||||
run: yarn format:check
|
||||
- name: Build
|
||||
run: yarn build
|
||||
|
||||
@@ -34,29 +36,29 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2
|
||||
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
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -86,15 +88,15 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v2
|
||||
uses: technote-space/workflow-conclusion-action@v3
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
run: |
|
||||
failures=(neutral, skipped, timed_out, action_required)
|
||||
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||
echo ::set-output name=status::failure
|
||||
echo "status=failure" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
||||
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- name: Post Status to Discord
|
||||
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 }}'
|
||||
30
.github/workflows/cypress.yml
vendored
Normal file
30
.github/workflows/cypress.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Cypress Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
cypress-run:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Cypress run
|
||||
uses: cypress-io/github-action@v4
|
||||
with:
|
||||
build: yarn cypress:build
|
||||
start: yarn start
|
||||
wait-on: 'http://localhost:5055'
|
||||
record: true
|
||||
env:
|
||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
WITH_MIGRATIONS: true
|
||||
# Fix test titles in cypress dashboard
|
||||
COMMIT_INFO_MESSAGE: ${{github.event.pull_request.title}}
|
||||
COMMIT_INFO_SHA: ${{github.event.pull_request.head.sha}}
|
||||
12
.github/workflows/preview.yml
vendored
12
.github/workflows/preview.yml
vendored
@@ -14,24 +14,24 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
- name: Get the 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
|
||||
uses: docker/setup-qemu-action@v1
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
|
||||
25
.github/workflows/release.yml
vendored
25
.github/workflows/release.yml
vendored
@@ -21,16 +21,16 @@ jobs:
|
||||
with:
|
||||
node-version: 16
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -69,12 +69,12 @@ jobs:
|
||||
run: |
|
||||
git fetch --prune --tags
|
||||
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
||||
echo ::set-output name=RELEASE::stable
|
||||
echo "RELEASE=stable" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo ::set-output name=RELEASE::edge
|
||||
echo "RELEASE=edge" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- 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
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
with:
|
||||
architecture: ${{ matrix.architecture }}
|
||||
- name: Upload Snap Package
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: overseerr-snap-package-${{ matrix.architecture }}
|
||||
path: ${{ steps.build.outputs.snap }}
|
||||
@@ -93,8 +93,9 @@ jobs:
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
- name: Publish Snap Package
|
||||
uses: snapcore/action-publish@v1
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
|
||||
with:
|
||||
store_login: ${{ secrets.SNAP_LOGIN }}
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
release: ${{ steps.prepare.outputs.RELEASE }}
|
||||
|
||||
@@ -105,15 +106,15 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v2
|
||||
uses: technote-space/workflow-conclusion-action@v3
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
run: |
|
||||
failures=(neutral, skipped, timed_out, action_required)
|
||||
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||
echo ::set-output name=status::failure
|
||||
echo "status=failure" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
||||
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- name: Post Status to Discord
|
||||
uses: sarisia/actions-status-discord@v1
|
||||
|
||||
89
.github/workflows/snap.yaml
vendored
Normal file
89
.github/workflows/snap.yaml
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
name: Publish Snap
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
jobs:
|
||||
name: Job Check
|
||||
runs-on: ubuntu-20.04
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/cancel-workflow-action@0.10.0
|
||||
with:
|
||||
access_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-snap:
|
||||
name: Build Snap Package (${{ matrix.architecture }})
|
||||
needs: jobs
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
architecture:
|
||||
- amd64
|
||||
- arm64
|
||||
- armhf
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
- name: Prepare
|
||||
id: prepare
|
||||
run: |
|
||||
git fetch --prune --unshallow --tags
|
||||
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
||||
echo "RELEASE=stable" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "RELEASE=edge" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Build Snap Package
|
||||
uses: diddlesnaps/snapcraft-multiarch-action@v1
|
||||
id: build
|
||||
with:
|
||||
architecture: ${{ matrix.architecture }}
|
||||
- name: Upload Snap Package
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: overseerr-snap-package-${{ matrix.architecture }}
|
||||
path: ${{ steps.build.outputs.snap }}
|
||||
- name: Review Snap Package
|
||||
uses: diddlesnaps/snapcraft-review-tools-action@v1
|
||||
with:
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
- name: Publish Snap Package
|
||||
uses: snapcore/action-publish@v1
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
|
||||
with:
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
release: ${{ steps.prepare.outputs.RELEASE }}
|
||||
|
||||
discord:
|
||||
name: Send Discord Notification
|
||||
needs: build-snap
|
||||
if: always() && !contains(github.event.head_commit.message, '[skip ci]')
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v3
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
run: |
|
||||
failures=(neutral, skipped, timed_out, action_required)
|
||||
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||
echo "status=failure" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- name: Post Status to Discord
|
||||
uses: sarisia/actions-status-discord@v1
|
||||
with:
|
||||
webhook: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
status: ${{ steps.status.outputs.status }}
|
||||
title: ${{ github.workflow }}
|
||||
nofail: true
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -53,3 +53,17 @@ config/db/db.sqlite3-journal
|
||||
|
||||
# VS Code
|
||||
.vscode/launch.json
|
||||
|
||||
# Cypress
|
||||
cypress.env.json
|
||||
cypress/videos
|
||||
cypress/screenshots
|
||||
|
||||
# ESLint
|
||||
.eslintcache
|
||||
|
||||
# TS Build Info
|
||||
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',
|
||||
};
|
||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -11,9 +11,6 @@
|
||||
// https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode
|
||||
"esbenp.prettier-vscode",
|
||||
|
||||
// https://marketplace.visualstudio.com/items?itemName=eg2.vscode-npm-script
|
||||
"eg2.vscode-npm-script",
|
||||
|
||||
// https://marketplace.visualstudio.com/items?itemName=Orta.vscode-jest
|
||||
"Orta.vscode-jest",
|
||||
|
||||
|
||||
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
@@ -15,8 +15,9 @@
|
||||
"database": "./config/db/db.sqlite3"
|
||||
}
|
||||
],
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": true
|
||||
},
|
||||
"editor.formatOnSave": true
|
||||
"editor.formatOnSave": true,
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"files.associations": {
|
||||
"globals.css": "tailwindcss"
|
||||
}
|
||||
}
|
||||
|
||||
262
CHANGELOG.md
262
CHANGELOG.md
@@ -1,3 +1,265 @@
|
||||
## [1.33.2](https://github.com/sct/overseerr/compare/v1.33.1...v1.33.2) (2023-07-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* handle issue causing incorrect media to change to unknown ([#3516](https://github.com/sct/overseerr/issues/3516)) ([83b008c](https://github.com/sct/overseerr/commit/83b008c8391459bd02dc74bcdb0d8caf27207bdf))
|
||||
|
||||
## [1.33.1](https://github.com/sct/overseerr/compare/v1.33.0...v1.33.1) (2023-07-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolved issue with create slider causing incorrect form submission ([#3514](https://github.com/sct/overseerr/issues/3514)) ([a761b7d](https://github.com/sct/overseerr/commit/a761b7dd35a5bd61bb4eb0275b75d1e0977e6a2d))
|
||||
* resolved user access check issue ([#3551](https://github.com/sct/overseerr/issues/3551)) ([2816c66](https://github.com/sct/overseerr/commit/2816c66300bf870d493c0665b0e984d60f707dfd))
|
||||
* **statusbadge:** handle missing season/episode number ([#3526](https://github.com/sct/overseerr/issues/3526)) ([01de972](https://github.com/sct/overseerr/commit/01de972a8fe2ea3c18d5b2f426d01b5b14d142d4))
|
||||
|
||||
# [1.33.0](https://github.com/sct/overseerr/compare/v1.32.5...v1.33.0) (2023-06-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* added a refresh interval if download status is in progress ([#3275](https://github.com/sct/overseerr/issues/3275)) ([1e2c6f4](https://github.com/sct/overseerr/commit/1e2c6f46ab66c836f321b5d8e34f1e8124c0b542))
|
||||
* adjust the plex watchlist sync schedule to have fuzziness ([#3502](https://github.com/sct/overseerr/issues/3502)) ([2c3f533](https://github.com/sct/overseerr/commit/2c3f5330764492e1323afd2d1f25e28ad78a2f2f))
|
||||
* availability sync file detection ([#3371](https://github.com/sct/overseerr/issues/3371)) ([7522aa3](https://github.com/sct/overseerr/commit/7522aa31743b169c903ebdf9d4d698645d27514c))
|
||||
* **build:** increase threshold for amount of data to be fetched when SSR'ing ([#3320](https://github.com/sct/overseerr/issues/3320)) ([d7b83d2](https://github.com/sct/overseerr/commit/d7b83d22cee3d20db564cc0564d42802b02327e3))
|
||||
* corrected initial fallback data load on details page ([#3395](https://github.com/sct/overseerr/issues/3395)) ([4bd8764](https://github.com/sct/overseerr/commit/4bd87647d0551c20e13589a62690a6f3e5ad8ff7))
|
||||
* correctly load series fallback modal with sonarr v4 ([#3451](https://github.com/sct/overseerr/issues/3451)) ([e051b1d](https://github.com/sct/overseerr/commit/e051b1dfea9c9320cc9dd420c475ae74cff0d901))
|
||||
* **deps:** update all non-major dependencies ([#3223](https://github.com/sct/overseerr/issues/3223)) ([f5191ad](https://github.com/sct/overseerr/commit/f5191aded680357522a65bbdcc40d162b8fbf594))
|
||||
* error deleting users with over 1000 requests ([#3376](https://github.com/sct/overseerr/issues/3376)) ([ac77b03](https://github.com/sct/overseerr/commit/ac77b037d5fb0c54f5edf4b29d04adb57aef388f))
|
||||
* fixes RT ratings for tv shows ([#3492](https://github.com/sct/overseerr/issues/3492)) ([04fbd00](https://github.com/sct/overseerr/commit/04fbd00d4ac29045592588ef8b664d1916991e37)), closes [#3491](https://github.com/sct/overseerr/issues/3491)
|
||||
* **genreselector:** fix searching in Genre filter ([#3468](https://github.com/sct/overseerr/issues/3468)) ([d7fa35e](https://github.com/sct/overseerr/commit/d7fa35e066cf371797aaa46ca464aa531ba8fb35))
|
||||
* handle search results with collections ([#3393](https://github.com/sct/overseerr/issues/3393)) ([70b1540](https://github.com/sct/overseerr/commit/70b1540ae23e83e01013856a9e06ad39e600922d))
|
||||
* improved handling of edge case that could cause availability sync to fail ([#3497](https://github.com/sct/overseerr/issues/3497)) ([d0836ce](https://github.com/sct/overseerr/commit/d0836ce0efd55fccf2546087a0c4f94f7cb2e82a))
|
||||
* lock body scroll when using webkit ([#3399](https://github.com/sct/overseerr/issues/3399)) ([c27f960](https://github.com/sct/overseerr/commit/c27f96096ac8cc6c387f9d1dde5b263576ac2132))
|
||||
* logger was set to info for the wrong logs ([#3354](https://github.com/sct/overseerr/issues/3354)) ([c36a4ba](https://github.com/sct/overseerr/commit/c36a4ba2b8df05873f5dfd0946a9bc3dc4ecfd1d))
|
||||
* make a (shallow) copy of radarr/sonarr tags into a request before adding user tags ([#3485](https://github.com/sct/overseerr/issues/3485)) ([48f7666](https://github.com/sct/overseerr/commit/48f76662d5c08156f1da3f47e216c5f02668f64b))
|
||||
* remove unnecessary parenthesis from api key generation ([#3336](https://github.com/sct/overseerr/issues/3336)) ([6bd3f01](https://github.com/sct/overseerr/commit/6bd3f015d65507efca60279007bd2b86ee860643))
|
||||
* **ui:** corrected default badge hover opacity ([#3369](https://github.com/sct/overseerr/issues/3369)) ([a4d07f5](https://github.com/sct/overseerr/commit/a4d07f5afab613317d96c9c6e9b47157a5a28986))
|
||||
* **ui:** corrected issues icon color ([#3498](https://github.com/sct/overseerr/issues/3498)) ([c1a47bd](https://github.com/sct/overseerr/commit/c1a47bd9de332cb4925974690f5a33448b5cc2e6))
|
||||
* **ui:** corrected mobile menu spacing in collection details ([#3432](https://github.com/sct/overseerr/issues/3432)) ([77a33cb](https://github.com/sct/overseerr/commit/77a33cb74d744bb747b791785799b632af8c7862))
|
||||
* **ui:** hide mini status badge if non-4K media status is unknown ([#3346](https://github.com/sct/overseerr/issues/3346)) ([50f06da](https://github.com/sct/overseerr/commit/50f06dabbffc693f0843584a64d1d96e77982820))
|
||||
* **ui:** hide search bar behind slideover when opened ([#3348](https://github.com/sct/overseerr/issues/3348)) ([b3882de](https://github.com/sct/overseerr/commit/b3882de8930a70adb2f93a27be6370bfa1826587))
|
||||
* **ui:** prevent title cards from flickering when quickly hovering across them ([#3349](https://github.com/sct/overseerr/issues/3349)) ([eb5502a](https://github.com/sct/overseerr/commit/eb5502a16f86e37a933f6beca0678c2d228e77d5))
|
||||
* **watchlist:** correctly load more than 20 watchlist items ([#3351](https://github.com/sct/overseerr/issues/3351)) ([af880a6](https://github.com/sct/overseerr/commit/af880a6c839794b34bddcd7e0fe56353aa48ba36))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* adds streaming services custom slider ([#3361](https://github.com/sct/overseerr/issues/3361)) ([2520d8f](https://github.com/sct/overseerr/commit/2520d8f739abfde608f3ef66a9fbe6b7b5c6647a))
|
||||
* auto tagging requested media with username ([#3338](https://github.com/sct/overseerr/issues/3338)) ([24f268b](https://github.com/sct/overseerr/commit/24f268b6cb67d9a8d8675cd6e09dd83a7f499add))
|
||||
* availability sync rework ([#3219](https://github.com/sct/overseerr/issues/3219)) ([ae38183](https://github.com/sct/overseerr/commit/ae3818304b2f75222d1bd223ece94f829a3b42d0)), closes [#377](https://github.com/sct/overseerr/issues/377)
|
||||
* **discover:** support filtering by tmdb user vote count on discover page ([#3407](https://github.com/sct/overseerr/issues/3407)) ([aa84977](https://github.com/sct/overseerr/commit/aa849776809dfe891e67ff4db6861ef44df1a774))
|
||||
* full title of download item on hover with tooltip ([#3296](https://github.com/sct/overseerr/issues/3296)) ([33e7691](https://github.com/sct/overseerr/commit/33e7691b94d7d369a0a1410e434850bc51e5572e))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **imageproxy:** do not set cookies to image proxy so CDNs can cache images ([#3332](https://github.com/sct/overseerr/issues/3332)) ([966639d](https://github.com/sct/overseerr/commit/966639df430d32f6bfebdb16314dc4590d21caf8))
|
||||
|
||||
## [1.32.5](https://github.com/sct/overseerr/compare/v1.32.4...v1.32.5) (2023-01-31)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **snapcraft:** use the correct config folder for image cache ([#3302](https://github.com/sct/overseerr/issues/3302)) ([c93467b](https://github.com/sct/overseerr/commit/c93467b3acf2c256324297e7e8f21e9944005dd4))
|
||||
|
||||
## [1.32.4](https://github.com/sct/overseerr/compare/v1.32.3...v1.32.4) (2023-01-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ui:** correct range slider styling in chrome ([#3299](https://github.com/sct/overseerr/issues/3299)) ([d954328](https://github.com/sct/overseerr/commit/d9543289111d72245564d25d300a71b0ea3954ba))
|
||||
|
||||
## [1.32.3](https://github.com/sct/overseerr/compare/v1.32.2...v1.32.3) (2023-01-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* pass in library type when scanning recently added items ([#3287](https://github.com/sct/overseerr/issues/3287)) ([8942eb8](https://github.com/sct/overseerr/commit/8942eb8b7c4fa1d16aa2e72e8ba7120a653c9aa2))
|
||||
* **ui:** air date will use UTC for timezone ([#3297](https://github.com/sct/overseerr/issues/3297)) ([3e43586](https://github.com/sct/overseerr/commit/3e43586acc0804c3fff524509caa890a104e132b))
|
||||
* **ui:** show 5 icons when possible on mobile menu ([#3298](https://github.com/sct/overseerr/issues/3298)) ([7040da1](https://github.com/sct/overseerr/commit/7040da1334f6d18e19a494c73caa17f7df552dfe))
|
||||
* **ui:** style range thumbs correctly for firefox ([#3294](https://github.com/sct/overseerr/issues/3294)) ([9d10e6a](https://github.com/sct/overseerr/commit/9d10e6a88c0996671f1d9d20792e1930dbc82329))
|
||||
|
||||
## [1.32.2](https://github.com/sct/overseerr/compare/v1.32.1...v1.32.2) (2023-01-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **build:** update usage of publish snap action ([#3272](https://github.com/sct/overseerr/issues/3272)) ([51b05cd](https://github.com/sct/overseerr/commit/51b05cd8fbb5d332807d8c00b2ffb7b10c3d0179))
|
||||
* multiple genre filtering now works ([#3282](https://github.com/sct/overseerr/issues/3282)) ([5076938](https://github.com/sct/overseerr/commit/507693881b939819413f0959df5ef6b7a357eb5c))
|
||||
* **ui:** series first air date sorting ([#3283](https://github.com/sct/overseerr/issues/3283)) ([374c78c](https://github.com/sct/overseerr/commit/374c78c989cc86bb144a954a91d5d183c4b591c0))
|
||||
|
||||
## [1.32.1](https://github.com/sct/overseerr/compare/v1.32.0...v1.32.1) (2023-01-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correctly check mobile menu permissions ([#3271](https://github.com/sct/overseerr/issues/3271)) ([f4a22dc](https://github.com/sct/overseerr/commit/f4a22dc437404558f301ccfc195cf0a300dd1ff2))
|
||||
|
||||
# [1.32.0](https://github.com/sct/overseerr/compare/v1.31.0...v1.32.0) (2023-01-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add bg-opacity to in-progress status badges ([#3190](https://github.com/sct/overseerr/issues/3190)) ([68223f4](https://github.com/sct/overseerr/commit/68223f4b1e98b01825516dcba39cbb2d3df31a70))
|
||||
* added download status and title to request card/item error components ([#3186](https://github.com/sct/overseerr/issues/3186)) ([3309f77](https://github.com/sct/overseerr/commit/3309f77aa4be1d70b27693531c119a8e26822518))
|
||||
* arrow icons were misplaced on mobile in slider edit ([#3260](https://github.com/sct/overseerr/issues/3260)) ([d328485](https://github.com/sct/overseerr/commit/d328485161b9cae6a70ef0713b4878207bc6015e))
|
||||
* changed overflow scroll to only if necessary ([#3184](https://github.com/sct/overseerr/issues/3184)) ([27feeea](https://github.com/sct/overseerr/commit/27feeea69121336557deda1f32b65a5daa146f82))
|
||||
* convert genre/studio to string in create slider ([#3201](https://github.com/sct/overseerr/issues/3201)) ([93afead](https://github.com/sct/overseerr/commit/93afead92e497f2e5bce67a34fffdaa08d20c7f2))
|
||||
* correct checkbox position (again) for slider edits ([#3227](https://github.com/sct/overseerr/issues/3227)) ([3ba6df1](https://github.com/sct/overseerr/commit/3ba6df1a41c084c4a6a90354338047623abef521))
|
||||
* correct grid sizing for webkit on streaming services ([#3248](https://github.com/sct/overseerr/issues/3248)) ([6fd11cf](https://github.com/sct/overseerr/commit/6fd11cf4254e1a19310592bec78a6de52bc073a8))
|
||||
* correct issue detail bottom padding on mobile displays ([#3268](https://github.com/sct/overseerr/issues/3268)) ([3db010b](https://github.com/sct/overseerr/commit/3db010b9eaec62aa08d973a61caf1801471bbf3e))
|
||||
* correct link to correct keyword results for series ([#3208](https://github.com/sct/overseerr/issues/3208)) ([4e9be7a](https://github.com/sct/overseerr/commit/4e9be7a3f7304ee7be5ee6fd34b1ea8f6c0cf399))
|
||||
* correct spacing between sliders ([#3225](https://github.com/sct/overseerr/issues/3225)) ([62e2de7](https://github.com/sct/overseerr/commit/62e2de70bf37b72d5f63370b662d4103a642775b))
|
||||
* correctly restore selected streaming service filters ([#3249](https://github.com/sct/overseerr/issues/3249)) ([154f3e7](https://github.com/sct/overseerr/commit/154f3e72efbf0b663358b3029156f54516f01a2f))
|
||||
* create shared class to add bottom spacing ([#3269](https://github.com/sct/overseerr/issues/3269)) ([5d1c6f7](https://github.com/sct/overseerr/commit/5d1c6f706555613d97ed9e61d8b665543c2f239b))
|
||||
* **deps:** pin dependency @headlessui/react to 1.7.7 ([#3194](https://github.com/sct/overseerr/issues/3194)) [skip ci] ([c4b16ab](https://github.com/sct/overseerr/commit/c4b16abc62647c74215155942a4230a31a238677))
|
||||
* **deps:** update dependency @heroicons/react to v2 ([#2970](https://github.com/sct/overseerr/issues/2970)) ([dd48d59](https://github.com/sct/overseerr/commit/dd48d59b20e2d1800ea30912116f4a4f1bb7928f))
|
||||
* **deps:** update dependency axios to v1 ([#3202](https://github.com/sct/overseerr/issues/3202)) ([421029e](https://github.com/sct/overseerr/commit/421029ebab66c9a6622ba47e56d7f6473524cce4))
|
||||
* **deps:** update dependency swr to v2 ([#3212](https://github.com/sct/overseerr/issues/3212)) ([7b6db50](https://github.com/sct/overseerr/commit/7b6db50ae55b1fc60d19a5cff62dd46bb989fa51))
|
||||
* **experimental:** use new RT API (sorta) ([#3179](https://github.com/sct/overseerr/issues/3179)) ([357cab8](https://github.com/sct/overseerr/commit/357cab87ac7752b8e119b51c938b343c661d83c2))
|
||||
* improve small screen layout for discover editing ([#3221](https://github.com/sct/overseerr/issues/3221)) ([d23b213](https://github.com/sct/overseerr/commit/d23b2132de05f072f7f9daad83d81421d747cf99))
|
||||
* include new package calendar css in build ([#3235](https://github.com/sct/overseerr/issues/3235)) ([c2a1a20](https://github.com/sct/overseerr/commit/c2a1a20a3bb20039a1936c7fe0ecb9e8311a0aea))
|
||||
* issues with issues ([#3267](https://github.com/sct/overseerr/issues/3267)) ([fd21971](https://github.com/sct/overseerr/commit/fd219717c01c558814d7a80de6304272b5a7944e))
|
||||
* prevent double encode if we are on /search endpoint ([#3238](https://github.com/sct/overseerr/issues/3238)) ([a343f8a](https://github.com/sct/overseerr/commit/a343f8ad915491a9c81512c7e541a1dac8906025))
|
||||
* **request:** approve request when retrying request ([#3234](https://github.com/sct/overseerr/issues/3234)) ([b515701](https://github.com/sct/overseerr/commit/b5157010c46cd9083993d5ee0172007b83d631da))
|
||||
* **request:** mark request as approved if media is already available when retrying failed request ([#3244](https://github.com/sct/overseerr/issues/3244)) ([cb65074](https://github.com/sct/overseerr/commit/cb650745f6a33e69391a633e6d272831f314e098))
|
||||
* restore border to ghost button and fix discover slider visibility toggle position ([#3226](https://github.com/sct/overseerr/issues/3226)) ([2eebb7f](https://github.com/sct/overseerr/commit/2eebb7fd3941b34fe9472aaf9d28265df8cce311))
|
||||
* restore status badges on titles on actors page when hide available media enabled ([#3206](https://github.com/sct/overseerr/issues/3206)) ([9d3446d](https://github.com/sct/overseerr/commit/9d3446d370499c3251159393e5c791b01225e05c))
|
||||
* screen would zoom on mobile if date picker input was selected ([#3241](https://github.com/sct/overseerr/issues/3241)) ([3aefddd](https://github.com/sct/overseerr/commit/3aefddd48834d86150d5f5cceb2d08af3a78847b))
|
||||
* series displayed an empty season with series list/request modal ([#3147](https://github.com/sct/overseerr/issues/3147)) ([2179637](https://github.com/sct/overseerr/commit/2179637d437999290eaa4152f6f37c71fc3d8ba3))
|
||||
* tooltip shows properly if not in progress ([#3185](https://github.com/sct/overseerr/issues/3185)) ([6face8c](https://github.com/sct/overseerr/commit/6face8cc4564b978fb98af32659b326d8c5cede8))
|
||||
* update StatusBadgeMini to shrink on title cards (and remove ring) ([#3210](https://github.com/sct/overseerr/issues/3210)) ([042a1a9](https://github.com/sct/overseerr/commit/042a1a950fdd4d4a61edf4bc19657f9b7a526da8))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add discover customization ([#3182](https://github.com/sct/overseerr/issues/3182)) ([cd35748](https://github.com/sct/overseerr/commit/cd3574851a12517cbfadc109e6412a7a9e44c114))
|
||||
* add keywords to movie/series detail pages ([#3204](https://github.com/sct/overseerr/issues/3204)) ([e084649](https://github.com/sct/overseerr/commit/e084649878a58c296786141d12dd69a69a27ee85))
|
||||
* add streaming services filter ([#3247](https://github.com/sct/overseerr/issues/3247)) ([1154156](https://github.com/sct/overseerr/commit/1154156459403494e8daf0c89a3ba356aeea1d97))
|
||||
* discover inline customization ([#3220](https://github.com/sct/overseerr/issues/3220)) ([8bd10b5](https://github.com/sct/overseerr/commit/8bd10b5bf3d1b8069872b616c7c8596caeb4937e))
|
||||
* discover overhaul (filters!) ([#3232](https://github.com/sct/overseerr/issues/3232)) ([dd00e48](https://github.com/sct/overseerr/commit/dd00e48f59054b44bef6b32a2c169e59f6175051))
|
||||
* discover slider edit arrow buttons for reordering ([#3259](https://github.com/sct/overseerr/issues/3259)) ([da00d45](https://github.com/sct/overseerr/commit/da00d454e17e8b00d04f6e26f6dd5153ed6ced81))
|
||||
* **lang:** translations update from Hosted Weblate ([#3030](https://github.com/sct/overseerr/issues/3030)) ([0d8b390](https://github.com/sct/overseerr/commit/0d8b390b678731e76bd1f0f8a0a4952c11e77f4d))
|
||||
* new mobile menu ([#3251](https://github.com/sct/overseerr/issues/3251)) ([fcbca17](https://github.com/sct/overseerr/commit/fcbca1722f31f32633a57bc5048f46c9da057d87))
|
||||
* translations update from Hosted Weblate ([#3218](https://github.com/sct/overseerr/issues/3218)) ([5940ff7](https://github.com/sct/overseerr/commit/5940ff7f5f62eed9ac5aa6f02803418aaa09813a))
|
||||
* **ui:** add episode number to front of episode name in season details ([#3086](https://github.com/sct/overseerr/issues/3086)) ([a672b32](https://github.com/sct/overseerr/commit/a672b324ec391a20f6f3a1daed82a8d276a52c2c))
|
||||
* **ui:** request card progress bar ([#3123](https://github.com/sct/overseerr/issues/3123)) ([03853a1](https://github.com/sct/overseerr/commit/03853a1b9155c8a2153c8885022a74619af1bc15))
|
||||
|
||||
# [1.31.0](https://github.com/sct/overseerr/compare/v1.30.1...v1.31.0) (2022-12-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** handle auth for accounts where the plex id may have been set to null ([#3125](https://github.com/sct/overseerr/issues/3125)) ([15e2469](https://github.com/sct/overseerr/commit/15e246929bdbc2b7b5bdab7a84bd7882b79d5cb1))
|
||||
* improved PTR scrolling performance ([#3095](https://github.com/sct/overseerr/issues/3095)) ([07ec3ef](https://github.com/sct/overseerr/commit/07ec3efbcaf669de7ccde4421c1112bfd23675d6))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* custom image proxy ([#3056](https://github.com/sct/overseerr/issues/3056)) ([500cd1f](https://github.com/sct/overseerr/commit/500cd1f872942923d2b9c3b835e6329e335d4a3f))
|
||||
* **lang:** add Croatian display language ([#3041](https://github.com/sct/overseerr/issues/3041)) ([64aab6d](https://github.com/sct/overseerr/commit/64aab6dd8240e191026512733b34cc046b6e508a))
|
||||
|
||||
## [1.30.1](https://github.com/sct/overseerr/compare/v1.30.0...v1.30.1) (2022-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* added deep links to issues and status badges ([#3065](https://github.com/sct/overseerr/issues/3065)) ([bfe56c3](https://github.com/sct/overseerr/commit/bfe56c347073001795b1c3e917eb7a5afcc4462c))
|
||||
* remove backdrop-blur class from warning buttons ([#3037](https://github.com/sct/overseerr/issues/3037)) ([430b1ab](https://github.com/sct/overseerr/commit/430b1ab871f8e3eefbebb37c74aa1ce3f0862efe))
|
||||
* sidebar close button placement when using PWA ([#3045](https://github.com/sct/overseerr/issues/3045)) ([21d20fd](https://github.com/sct/overseerr/commit/21d20fdfd61b7a5a2ec265d420aec103b1430a06))
|
||||
* **ui:** hide null dates in episodes list ([#3035](https://github.com/sct/overseerr/issues/3035)) ([7404d68](https://github.com/sct/overseerr/commit/7404d68143e830df73b9d2779a6d7ea65bc9fd4f))
|
||||
* **ui:** minor fixes ([#3036](https://github.com/sct/overseerr/issues/3036)) ([f5c2fc1](https://github.com/sct/overseerr/commit/f5c2fc1c209b2d04f0e39a97d8b65bcac00667dc))
|
||||
* update API docs to allow 'all' seasons value ([#3073](https://github.com/sct/overseerr/issues/3073)) ([1dfa943](https://github.com/sct/overseerr/commit/1dfa9431a95e7e2a1843746c2473d8a06f03e184))
|
||||
|
||||
# [1.30.0](https://github.com/sct/overseerr/compare/v1.29.1...v1.30.0) (2022-09-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** add rate limiter to TMDb requests to hopefully deal with 429s ([#2881](https://github.com/sct/overseerr/issues/2881)) ([aed1409](https://github.com/sct/overseerr/commit/aed1409f29d5b6360e87381d78dfeb4cc86d6fc6)), closes [#2853](https://github.com/sct/overseerr/issues/2853)
|
||||
* **api:** ignore filter if unset in media route ([#2647](https://github.com/sct/overseerr/issues/2647)) ([a6c1f3f](https://github.com/sct/overseerr/commit/a6c1f3f7ce498e32817cc8c74d439e8d99d6fbf4))
|
||||
* **api:** lookup shows using english title only ([#2911](https://github.com/sct/overseerr/issues/2911)) ([004e1bb](https://github.com/sct/overseerr/commit/004e1bb17e14bef1697b6b993f6ff92b77cdaeb4)), closes [#2801](https://github.com/sct/overseerr/issues/2801)
|
||||
* **api:** use correct path param type in openapi spec ([#2834](https://github.com/sct/overseerr/issues/2834)) ([6cd0c9b](https://github.com/sct/overseerr/commit/6cd0c9b2c81db2728ce09a9bc273a9aee366acbc))
|
||||
* better ordering of RequestButton options & properly handle failed requests ([#2944](https://github.com/sct/overseerr/issues/2944)) ([c143c0b](https://github.com/sct/overseerr/commit/c143c0b8d285896a7ad5f42f2a7dc1e38b55cc3a))
|
||||
* check perms to view watchlist slider on user profile ([#2980](https://github.com/sct/overseerr/issues/2980)) ([5d73bc2](https://github.com/sct/overseerr/commit/5d73bc22389a2606ecc630f88b6ec2328f690975))
|
||||
* clicking outside modal closes modal again ([#2984](https://github.com/sct/overseerr/issues/2984)) ([1a00532](https://github.com/sct/overseerr/commit/1a0053221b58d198bdcca0249fcd76226122c6a2))
|
||||
* compatibility issue with safari ([#3019](https://github.com/sct/overseerr/issues/3019)) ([e486623](https://github.com/sct/overseerr/commit/e486623310b1c0de12a5b38bf934543fde250c37))
|
||||
* correct safe margin for slideover ([#2977](https://github.com/sct/overseerr/issues/2977)) ([23409e6](https://github.com/sct/overseerr/commit/23409e6f2ffb060e7914db28375e141a6087ddfa))
|
||||
* correct spacing on season header badges ([#2983](https://github.com/sct/overseerr/issues/2983)) ([c4aa08f](https://github.com/sct/overseerr/commit/c4aa08f5f0bcc856e993416936cc74e4db6aed5d))
|
||||
* **deps:** pin dependencies ([#2946](https://github.com/sct/overseerr/issues/2946)) [skip ci] ([103c4ca](https://github.com/sct/overseerr/commit/103c4ca49ca6f7a8101a18a540d3955a1ac6eae7))
|
||||
* **deps:** pin dependency @formatjs/intl-utils to 3.8.4 ([#2975](https://github.com/sct/overseerr/issues/2975)) [skip ci] ([baf1ea9](https://github.com/sct/overseerr/commit/baf1ea95a38537fab56cc324aa9359f40b615fe6))
|
||||
* **deps:** pin dependency @headlessui/react to v0.0.0-insiders.b301f04 ([#2993](https://github.com/sct/overseerr/issues/2993)) [skip ci] ([833f52d](https://github.com/sct/overseerr/commit/833f52de56cc4715673c5d2593f05528d260ae15))
|
||||
* **deps:** pin dependency cronstrue to 2.11.0 ([#3018](https://github.com/sct/overseerr/issues/3018)) [skip ci] ([f20ba3f](https://github.com/sct/overseerr/commit/f20ba3fc2e6cd42c2c73ba47812d9d177e75a8c2))
|
||||
* **deps:** pin dependency react-popper-tooltip to 4.4.2 ([#2952](https://github.com/sct/overseerr/issues/2952)) [skip ci] ([5247f14](https://github.com/sct/overseerr/commit/5247f14968c1f5a1a2efdc26f850cf81c0c04f0e))
|
||||
* do not display 'Request More' button if no requestable seasons ([#2998](https://github.com/sct/overseerr/issues/2998)) ([23f93e3](https://github.com/sct/overseerr/commit/23f93e311d3c4ed413b2fabca520b5c97869c9b8))
|
||||
* failure to load SearchByNameModal ([#3000](https://github.com/sct/overseerr/issues/3000)) ([410ad0d](https://github.com/sct/overseerr/commit/410ad0d4b40e2ae3ca315a9c808a886ab2c7bf2d))
|
||||
* **frontend:** better request/media cards for items without valid TMDb IDs ([#2181](https://github.com/sct/overseerr/issues/2181)) ([9bc1f89](https://github.com/sct/overseerr/commit/9bc1f8977707f2d3383a7af8efa0521ced096777))
|
||||
* **frontend:** only allow 'request as' users w/ request perms ([#2991](https://github.com/sct/overseerr/issues/2991)) ([dbdecb1](https://github.com/sct/overseerr/commit/dbdecb1e0afb450e0cda6965a6d89408a831db0e))
|
||||
* issues and login page still had incorrect animations ([#2979](https://github.com/sct/overseerr/issues/2979)) ([095048d](https://github.com/sct/overseerr/commit/095048d94af28f25f88f956aba8e01d1199082f3))
|
||||
* **lang:** correct capitalization of 'TMDB' ([#2953](https://github.com/sct/overseerr/issues/2953)) ([9021696](https://github.com/sct/overseerr/commit/9021696cf085bd6387701811e64466e14893d1db))
|
||||
* **lang:** manage movie -> manage series ([#2963](https://github.com/sct/overseerr/issues/2963)) ([f5e6b62](https://github.com/sct/overseerr/commit/f5e6b620c133e38cf18978a619d3d4757bbab755))
|
||||
* log level value should not be case sensitive ([#2913](https://github.com/sct/overseerr/issues/2913)) ([6428b8d](https://github.com/sct/overseerr/commit/6428b8d4195c736a384441a761a746a35a3ad009))
|
||||
* manual browser refresh would redirect to home on search page ([#2692](https://github.com/sct/overseerr/issues/2692)) ([b287839](https://github.com/sct/overseerr/commit/b2878390b486e338151f26a2354711147012f88e)), closes [#2683](https://github.com/sct/overseerr/issues/2683)
|
||||
* new status indicators added to series list on mobile ([#3024](https://github.com/sct/overseerr/issues/3024)) ([407af32](https://github.com/sct/overseerr/commit/407af32d32a30d23ee14a9ff763cb4aa582d3ede))
|
||||
* **plex:** add container-size header to recently added api call ([#3023](https://github.com/sct/overseerr/issues/3023)) ([d8da5cb](https://github.com/sct/overseerr/commit/d8da5cbe9d0700e02cbece70caf9103bc5376505))
|
||||
* **recommendations:** only load more titles if there can be more than 40 ([#2749](https://github.com/sct/overseerr/issues/2749)) ([14519ef](https://github.com/sct/overseerr/commit/14519ef5559038b0d9d037a2bdc5d98e63c9db6f)), closes [#2710](https://github.com/sct/overseerr/issues/2710)
|
||||
* scroll restoration ([#3005](https://github.com/sct/overseerr/issues/3005)) ([14280c5](https://github.com/sct/overseerr/commit/14280c54370fd9a3e73120e4208e36183d39f9a3))
|
||||
* **search:** use correct param to filter movies by year ([#2727](https://github.com/sct/overseerr/issues/2727)) ([1054b4e](https://github.com/sct/overseerr/commit/1054b4e2d7262d841fa83cde624f1138ad7bd23a))
|
||||
* settings log modal when closing ([#2985](https://github.com/sct/overseerr/issues/2985)) ([4d56320](https://github.com/sct/overseerr/commit/4d563208709fe581ef6bf475e969197d586d907b))
|
||||
* transition animation ([#2974](https://github.com/sct/overseerr/issues/2974)) ([98028bf](https://github.com/sct/overseerr/commit/98028bf2f4d29175fa0922e744fee168849d653f))
|
||||
* **ui:** don't show 0 playcount in slideover ([#2714](https://github.com/sct/overseerr/issues/2714)) ([29be659](https://github.com/sct/overseerr/commit/29be6595125017700eccb34d33a0e852f23c97ba))
|
||||
* **ui:** hide 'Recently Added' & 'Recent Requests' sliders when empty ([#2190](https://github.com/sct/overseerr/issues/2190)) ([03d5e56](https://github.com/sct/overseerr/commit/03d5e56678c3a372114a0256ee3431deee42cb57))
|
||||
* **ui:** rectangular avatars getting stretched ([#2782](https://github.com/sct/overseerr/issues/2782)) ([db05172](https://github.com/sct/overseerr/commit/db05172d8b924a591ece4fae72d076eb59ee5f82))
|
||||
* **ui:** remove 'all' badge from request cards ([#2992](https://github.com/sct/overseerr/issues/2992)) ([5c01313](https://github.com/sct/overseerr/commit/5c01313cc4c8277de398124848b4481a2b0080b3))
|
||||
* update Discord ID regex to include 19 digit IDs ([#2860](https://github.com/sct/overseerr/issues/2860)) ([9da8461](https://github.com/sct/overseerr/commit/9da84612254fe0c36b38bc49184cc1fc52ff6212))
|
||||
* use fallbackData to prepare user data during SSR ([#2968](https://github.com/sct/overseerr/issues/2968)) ([6e60a27](https://github.com/sct/overseerr/commit/6e60a275c7c2451beb1922d66eaa6c5411d36c3d))
|
||||
* use image.tmdb.org for setup/login backdrop images ([#2966](https://github.com/sct/overseerr/issues/2966)) ([3b26338](https://github.com/sct/overseerr/commit/3b2633812b5ca8cde010b3f8c2ea9a8646b33e1d))
|
||||
* username will not show undefined on cancel or delete ([#2982](https://github.com/sct/overseerr/issues/2982)) ([b925857](https://github.com/sct/overseerr/commit/b925857dfa27483558eab24bc5f20f654e294a08))
|
||||
* watch data not required to show Tautulli button ([#2976](https://github.com/sct/overseerr/issues/2976)) ([dd28200](https://github.com/sct/overseerr/commit/dd282000407ae9656640f38561a104916d12571a))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add 20th Century Studios to Studio Slider ([#2288](https://github.com/sct/overseerr/issues/2288)) ([b33956e](https://github.com/sct/overseerr/commit/b33956e6b85b2c2fc12e23951c06f36e009fb627))
|
||||
* **api:** add issue counts endpoint ([#2713](https://github.com/sct/overseerr/issues/2713)) ([e4039d0](https://github.com/sct/overseerr/commit/e4039d09c0380d80f03c7a00b51a150f88c02cca))
|
||||
* **discover:** add Paramount+ to network slider ([#2608](https://github.com/sct/overseerr/issues/2608)) ([1d00229](https://github.com/sct/overseerr/commit/1d00229a485bb2b376e9f63b52c70c7719f5f023))
|
||||
* **frontend:** a few more tooltips ([#2972](https://github.com/sct/overseerr/issues/2972)) ([815d709](https://github.com/sct/overseerr/commit/815d709bcfa4cca1528cc697defe9cd773ea0089))
|
||||
* **frontend:** add more tooltips ([#2961](https://github.com/sct/overseerr/issues/2961)) ([950b171](https://github.com/sct/overseerr/commit/950b1712b7449dc48a9c1589950907dd581f069a))
|
||||
* improved user dropdown ([#2969](https://github.com/sct/overseerr/issues/2969)) ([67f3a38](https://github.com/sct/overseerr/commit/67f3a3829e2f629e72ec3c32b042be2ef08c38e9))
|
||||
* **jobs:** show current job frequency in edit modal ([#3008](https://github.com/sct/overseerr/issues/3008)) ([99fc9a2](https://github.com/sct/overseerr/commit/99fc9a2da01b1628d5f849ce56f016c0ab26c3db))
|
||||
* **lang:** add Arabic and Lithuanian display languages ([#2916](https://github.com/sct/overseerr/issues/2916)) ([3db3044](https://github.com/sct/overseerr/commit/3db3044210316817144ecacd81be97f159d0df2b))
|
||||
* **lang:** translations update from Hosted Weblate ([#2659](https://github.com/sct/overseerr/issues/2659)) ([e939dc6](https://github.com/sct/overseerr/commit/e939dc678e124bd1f630c98d1d4927dba6372af5))
|
||||
* **lang:** translations update from Hosted Weblate ([#2915](https://github.com/sct/overseerr/issues/2915)) ([a0301e2](https://github.com/sct/overseerr/commit/a0301e2d83b2fca07f69a2c274d4745edef6d0cd))
|
||||
* **lang:** translations update from Hosted Weblate ([#2958](https://github.com/sct/overseerr/issues/2958)) ([29ab178](https://github.com/sct/overseerr/commit/29ab178fb0048c055a86bc40f7b47117903a3b2c))
|
||||
* **lang:** translations update from Hosted Weblate ([#2971](https://github.com/sct/overseerr/issues/2971)) ([2950cf4](https://github.com/sct/overseerr/commit/2950cf4438e2d10a9c0543a848519a113527fe91))
|
||||
* **lang:** translations update from Hosted Weblate ([#2999](https://github.com/sct/overseerr/issues/2999)) ([8672869](https://github.com/sct/overseerr/commit/867286996b805fefd3c16f2a1a05d5f2c10daced))
|
||||
* **lang:** translations update from Hosted Weblate ([#3006](https://github.com/sct/overseerr/issues/3006)) ([611ceeb](https://github.com/sct/overseerr/commit/611ceeb5f49ec4760f157eafb03b2dae465d476e))
|
||||
* **lang:** translations update from Hosted Weblate ([#3014](https://github.com/sct/overseerr/issues/3014)) ([3d458dd](https://github.com/sct/overseerr/commit/3d458dd2fdc8807f09af8b4c29bed794b3886f0f))
|
||||
* **lang:** translations update from Hosted Weblate ([#3026](https://github.com/sct/overseerr/issues/3026)) ([16cb53f](https://github.com/sct/overseerr/commit/16cb53f703a6c42b99aa776515f90b3ed153b382))
|
||||
* **logs:** add search filter ([#2505](https://github.com/sct/overseerr/issues/2505)) ([30141f7](https://github.com/sct/overseerr/commit/30141f76e025763bf79fd3c8fb344d45519d5d8d))
|
||||
* **manage slideover:** show more request override details ([#2772](https://github.com/sct/overseerr/issues/2772)) ([90095bb](https://github.com/sct/overseerr/commit/90095bb18548dfd663a78df1908c40dbf2f99faf))
|
||||
* **notif:** auto-request notif type ([#2956](https://github.com/sct/overseerr/issues/2956)) ([6c0fd40](https://github.com/sct/overseerr/commit/6c0fd408779bc084698499bca861d042cd735d77))
|
||||
* **perms:** add new permission for viewing recently added media ([#2129](https://github.com/sct/overseerr/issues/2129)) ([a12697b](https://github.com/sct/overseerr/commit/a12697b06143e9a9c5c240c104faabdf2096ffd3))
|
||||
* plex deep links for iOS devices ([#2680](https://github.com/sct/overseerr/issues/2680)) ([575da30](https://github.com/sct/overseerr/commit/575da306b03eea3561de8d7dbe1b4b69674c7b2b))
|
||||
* plex watchlist sync integration ([#2885](https://github.com/sct/overseerr/issues/2885)) ([301f2bf](https://github.com/sct/overseerr/commit/301f2bf7ab0c5e7c5aef9d78a58d6449df0f55b8))
|
||||
* pull down to refresh ([#2908](https://github.com/sct/overseerr/issues/2908)) ([87825a0](https://github.com/sct/overseerr/commit/87825a0e058162e82634503c81deeff1a59634e5))
|
||||
* restore option to cache and optimize images locally ([#2964](https://github.com/sct/overseerr/issues/2964)) ([507227a](https://github.com/sct/overseerr/commit/507227aa496a60d1f46ce1a34c13bb162edc4bb6))
|
||||
* season/episode list on series details ([#2967](https://github.com/sct/overseerr/issues/2967)) ([8a2acb7](https://github.com/sct/overseerr/commit/8a2acb7f2bbe91feb3c6ee45c2066e3a036b83f9))
|
||||
* show alert/prompt when settings changes require restart ([#2401](https://github.com/sct/overseerr/issues/2401)) ([f3e56da](https://github.com/sct/overseerr/commit/f3e56da3b719285095a59d5a0e822087e095b709))
|
||||
* tooltip foundation ([#2950](https://github.com/sct/overseerr/issues/2950)) ([16545ee](https://github.com/sct/overseerr/commit/16545eec225ce942b55935019185a94e471fb93b))
|
||||
* **ui:** revalidate requests slider on discover page ([#2818](https://github.com/sct/overseerr/issues/2818)) ([91e0928](https://github.com/sct/overseerr/commit/91e0928aa0e1353431754750095cb64b93348c21))
|
||||
* user delete modal shows username and requires confirmation ([#2779](https://github.com/sct/overseerr/issues/2779)) ([36d17fe](https://github.com/sct/overseerr/commit/36d17fed6e4e1ca651a4e29f087b2abb53f794cf))
|
||||
* view other users' watchlists ([#2959](https://github.com/sct/overseerr/issues/2959)) ([0839718](https://github.com/sct/overseerr/commit/0839718806a04ee094445dd7276bba7f49424ab7))
|
||||
|
||||
## [1.29.1](https://github.com/sct/overseerr/compare/v1.29.0...v1.29.1) (2022-04-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** resolve local/password authentication issues ([#2677](https://github.com/sct/overseerr/issues/2677)) ([b75fc7b](https://github.com/sct/overseerr/commit/b75fc7b2384ce760432620faaa92277dcd42b8e1))
|
||||
|
||||
# [1.29.0](https://github.com/sct/overseerr/compare/v1.28.0...v1.29.0) (2022-04-01)
|
||||
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ When adding new UI text, please try to adhere to the following guidelines:
|
||||
1. Be concise and clear, and use as few words as possible to make your point.
|
||||
2. Use the Oxford comma where appropriate.
|
||||
3. Use the appropriate Unicode characters for ellipses, arrows, and other special characters/symbols.
|
||||
4. Capitalize proper nouns, such as Plex, Radarr, Sonarr, Telegram, Slack, Pushover, etc. Be sure to also use the official capitalization for any abbreviations; e.g., TMDb and IMDb have a lowercase 'b', whereas TheTVDB has a capital 'B'.
|
||||
4. Capitalize proper nouns, such as Plex, Radarr, Sonarr, Telegram, Slack, Pushover, etc. Be sure to also use the official capitalization for any abbreviations; e.g., IMDb has a lowercase 'b', whereas TMDB and TheTVDB have a capital 'B'.
|
||||
5. Title case headings, button text, and form labels. Note that verbs such as "is" should be capitalized, whereas prepositions like "from" should be lowercase (unless as the first or last word of the string, in which case they are also capitalized).
|
||||
6. Capitalize the first word in validation error messages, dropdowns, and form "tips." These strings should not end in punctuation.
|
||||
7. Ensure that toast notification strings are complete sentences ending in punctuation.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:16.14-alpine AS BUILD_IMAGE
|
||||
FROM node:16.17-alpine AS BUILD_IMAGE
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -14,7 +14,7 @@ RUN \
|
||||
esac
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile --network-timeout 1000000
|
||||
RUN CYPRESS_INSTALL_BINARY=0 yarn install --frozen-lockfile --network-timeout 1000000
|
||||
|
||||
COPY . ./
|
||||
|
||||
@@ -33,7 +33,7 @@ RUN touch config/DOCKER
|
||||
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
||||
|
||||
|
||||
FROM node:16.14-alpine
|
||||
FROM node:16.17-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:16.14-alpine
|
||||
FROM node:16.17-alpine
|
||||
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
223
README.md
223
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://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://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>
|
||||
<!-- 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-72-orange.svg"/></a>
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-96-orange.svg"/></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
</p>
|
||||
|
||||
@@ -74,100 +73,132 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
<!-- prettier-ignore-start -->
|
||||
<!-- markdownlint-disable -->
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><a href="https://sct.dev"><img src="https://avatars1.githubusercontent.com/u/234213?v=4?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>
|
||||
<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"><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"><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"><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"><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"><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>
|
||||
</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>
|
||||
<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"><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"><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"><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"><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"><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>
|
||||
</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>
|
||||
<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"><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"><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"><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"><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"><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>
|
||||
</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>
|
||||
<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"><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"><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"><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"><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"><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>
|
||||
</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>
|
||||
<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"><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"><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"><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"><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"><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>
|
||||
<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"><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"><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"><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"><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"><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>
|
||||
</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>
|
||||
<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"><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"><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"><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"><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"><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>
|
||||
</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>
|
||||
<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"><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"><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"><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"><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"><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>
|
||||
</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>
|
||||
<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"><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"><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"><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"><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"><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>
|
||||
</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>
|
||||
<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"><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"><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"><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"><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"><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>
|
||||
</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>
|
||||
<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>
|
||||
</tr>
|
||||
<tbody>
|
||||
<tr>
|
||||
<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" 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" 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" 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" 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" 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>
|
||||
<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" 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" 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" 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" 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" 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" 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>
|
||||
<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" 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" 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" 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" 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" 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" 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>
|
||||
<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" 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" 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" 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" 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" 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" 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>
|
||||
<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" 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" 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" 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" 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" 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" 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>
|
||||
<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" 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" 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" 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" 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" 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" 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>
|
||||
<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" 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" 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" 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" 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" 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" 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>
|
||||
<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" 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" 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" 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" 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" 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" 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>
|
||||
<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" 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" 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" 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" 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" 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" 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>
|
||||
<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" 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" 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" 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" 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" 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" 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>
|
||||
<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" 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" 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" 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" 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" 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" 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>
|
||||
<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" 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" 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>
|
||||
<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>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- markdownlint-restore -->
|
||||
|
||||
19
cypress.config.ts
Normal file
19
cypress.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'cypress';
|
||||
|
||||
export default defineConfig({
|
||||
projectId: 'onnqy3',
|
||||
e2e: {
|
||||
baseUrl: 'http://localhost:5055',
|
||||
experimentalSessionAndOrigin: true,
|
||||
},
|
||||
env: {
|
||||
ADMIN_EMAIL: 'admin@seerr.dev',
|
||||
ADMIN_PASSWORD: 'test1234',
|
||||
USER_EMAIL: 'friend@seerr.dev',
|
||||
USER_PASSWORD: 'test1234',
|
||||
},
|
||||
retries: {
|
||||
runMode: 2,
|
||||
openMode: 0,
|
||||
},
|
||||
});
|
||||
149
cypress/config/settings.cypress.json
Normal file
149
cypress/config/settings.cypress.json
Normal file
@@ -0,0 +1,149 @@
|
||||
{
|
||||
"clientId": "6919275e-142a-48d8-be6b-93594cbd4626",
|
||||
"vapidPrivate": "tmnslaO8ZWN6bNbSEv_rolPeBTlNxOwCCAHrM9oZz3M",
|
||||
"vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs",
|
||||
"main": {
|
||||
"apiKey": "testkey",
|
||||
"applicationTitle": "Overseerr",
|
||||
"applicationUrl": "",
|
||||
"csrfProtection": false,
|
||||
"cacheImages": false,
|
||||
"defaultPermissions": 32,
|
||||
"defaultQuotas": {
|
||||
"movie": {},
|
||||
"tv": {}
|
||||
},
|
||||
"hideAvailable": false,
|
||||
"localLogin": true,
|
||||
"newPlexLogin": true,
|
||||
"region": "",
|
||||
"originalLanguage": "",
|
||||
"trustProxy": false,
|
||||
"partialRequestsEnabled": true,
|
||||
"locale": "en"
|
||||
},
|
||||
"plex": {
|
||||
"name": "Seerr",
|
||||
"ip": "192.168.1.1",
|
||||
"port": 32400,
|
||||
"useSsl": false,
|
||||
"libraries": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Movies",
|
||||
"enabled": true,
|
||||
"type": "movie"
|
||||
}
|
||||
],
|
||||
"machineId": "test"
|
||||
},
|
||||
"tautulli": {},
|
||||
"radarr": [],
|
||||
"sonarr": [],
|
||||
"public": {
|
||||
"initialized": true
|
||||
},
|
||||
"notifications": {
|
||||
"agents": {
|
||||
"email": {
|
||||
"enabled": false,
|
||||
"options": {
|
||||
"emailFrom": "",
|
||||
"smtpHost": "",
|
||||
"smtpPort": 587,
|
||||
"secure": false,
|
||||
"ignoreTls": false,
|
||||
"requireTls": false,
|
||||
"allowSelfSigned": false,
|
||||
"senderName": "Overseerr"
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"webhookUrl": "",
|
||||
"enableMentions": true
|
||||
}
|
||||
},
|
||||
"lunasea": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"webhookUrl": ""
|
||||
}
|
||||
},
|
||||
"slack": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"webhookUrl": ""
|
||||
}
|
||||
},
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"botAPI": "",
|
||||
"chatId": "",
|
||||
"sendSilently": false
|
||||
}
|
||||
},
|
||||
"pushbullet": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"accessToken": ""
|
||||
}
|
||||
},
|
||||
"pushover": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"accessToken": "",
|
||||
"userToken": ""
|
||||
}
|
||||
},
|
||||
"webhook": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"webhookUrl": "",
|
||||
"jsonPayload": "IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i"
|
||||
}
|
||||
},
|
||||
"webpush": {
|
||||
"enabled": false,
|
||||
"options": {}
|
||||
},
|
||||
"gotify": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"url": "",
|
||||
"token": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"jobs": {
|
||||
"plex-recently-added-scan": {
|
||||
"schedule": "0 */5 * * * *"
|
||||
},
|
||||
"plex-full-scan": {
|
||||
"schedule": "0 0 3 * * *"
|
||||
},
|
||||
"radarr-scan": {
|
||||
"schedule": "0 0 4 * * *"
|
||||
},
|
||||
"sonarr-scan": {
|
||||
"schedule": "0 30 4 * * *"
|
||||
},
|
||||
"download-sync": {
|
||||
"schedule": "0 * * * * *"
|
||||
},
|
||||
"download-sync-reset": {
|
||||
"schedule": "0 0 1 * * *"
|
||||
}
|
||||
}
|
||||
}
|
||||
214
cypress/e2e/discover.cy.ts
Normal file
214
cypress/e2e/discover.cy.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
const clickFirstTitleCardInSlider = (sliderTitle: string): void => {
|
||||
cy.contains('.slider-header', sliderTitle)
|
||||
.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', sliderTitle)
|
||||
.next('[data-testid=media-slider]')
|
||||
.find('[data-testid=title-card]')
|
||||
.first()
|
||||
.click();
|
||||
cy.get('[data-testid=media-title]').should('contain', text);
|
||||
});
|
||||
};
|
||||
|
||||
describe('Discover', () => {
|
||||
beforeEach(() => {
|
||||
cy.loginAsAdmin();
|
||||
});
|
||||
|
||||
it('loads a trending item', () => {
|
||||
cy.intercept('/api/v1/discover/trending*').as('getTrending');
|
||||
cy.visit('/');
|
||||
cy.wait('@getTrending');
|
||||
clickFirstTitleCardInSlider('Trending');
|
||||
});
|
||||
|
||||
it('loads popular movies', () => {
|
||||
cy.intercept('/api/v1/discover/movies*').as('getPopularMovies');
|
||||
cy.visit('/');
|
||||
cy.wait('@getPopularMovies');
|
||||
clickFirstTitleCardInSlider('Popular Movies');
|
||||
});
|
||||
|
||||
it('loads upcoming movies', () => {
|
||||
cy.intercept('/api/v1/discover/movies?page=1&primaryReleaseDateGte*').as(
|
||||
'getUpcomingMovies'
|
||||
);
|
||||
cy.visit('/');
|
||||
cy.wait('@getUpcomingMovies');
|
||||
clickFirstTitleCardInSlider('Upcoming Movies');
|
||||
});
|
||||
|
||||
it('loads popular series', () => {
|
||||
cy.intercept('/api/v1/discover/tv*').as('getPopularTv');
|
||||
cy.visit('/');
|
||||
cy.wait('@getPopularTv');
|
||||
clickFirstTitleCardInSlider('Popular Series');
|
||||
});
|
||||
|
||||
it('loads upcoming series', () => {
|
||||
cy.intercept('/api/v1/discover/tv?page=1&firstAirDateGte=*').as(
|
||||
'getUpcomingSeries'
|
||||
);
|
||||
cy.visit('/');
|
||||
cy.wait('@getUpcomingSeries');
|
||||
clickFirstTitleCardInSlider('Upcoming Series');
|
||||
});
|
||||
|
||||
it('displays error for media with invalid TMDB ID', () => {
|
||||
cy.intercept('GET', '/api/v1/media?*', {
|
||||
pageInfo: { pages: 1, pageSize: 20, results: 1, page: 1 },
|
||||
results: [
|
||||
{
|
||||
downloadStatus: [],
|
||||
downloadStatus4k: [],
|
||||
id: 1922,
|
||||
mediaType: 'movie',
|
||||
tmdbId: 998814,
|
||||
tvdbId: null,
|
||||
imdbId: null,
|
||||
status: 5,
|
||||
status4k: 1,
|
||||
createdAt: '2022-08-18T18:11:13.000Z',
|
||||
updatedAt: '2022-08-18T19:56:41.000Z',
|
||||
lastSeasonChange: '2022-08-18T19:56:41.000Z',
|
||||
mediaAddedAt: '2022-08-18T19:56:41.000Z',
|
||||
serviceId: null,
|
||||
serviceId4k: null,
|
||||
externalServiceId: null,
|
||||
externalServiceId4k: null,
|
||||
externalServiceSlug: null,
|
||||
externalServiceSlug4k: null,
|
||||
ratingKey: null,
|
||||
ratingKey4k: null,
|
||||
seasons: [],
|
||||
},
|
||||
],
|
||||
}).as('getMedia');
|
||||
|
||||
cy.visit('/');
|
||||
cy.wait('@getMedia');
|
||||
cy.contains('.slider-header', 'Recently Added')
|
||||
.next('[data-testid=media-slider]')
|
||||
.find('[data-testid=title-card]')
|
||||
.first()
|
||||
.find('[data-testid=title-card-title]')
|
||||
.contains('Movie Not Found');
|
||||
});
|
||||
|
||||
it('displays error for request with invalid TMDB ID', () => {
|
||||
cy.intercept('GET', '/api/v1/request?*', {
|
||||
pageInfo: { pages: 1, pageSize: 10, results: 1, page: 1 },
|
||||
results: [
|
||||
{
|
||||
id: 582,
|
||||
status: 1,
|
||||
createdAt: '2022-08-18T18:11:13.000Z',
|
||||
updatedAt: '2022-08-18T18:11:13.000Z',
|
||||
type: 'movie',
|
||||
is4k: false,
|
||||
serverId: null,
|
||||
profileId: null,
|
||||
rootFolder: null,
|
||||
languageProfileId: null,
|
||||
tags: null,
|
||||
media: {
|
||||
downloadStatus: [],
|
||||
downloadStatus4k: [],
|
||||
id: 1922,
|
||||
mediaType: 'movie',
|
||||
tmdbId: 998814,
|
||||
tvdbId: null,
|
||||
imdbId: null,
|
||||
status: 2,
|
||||
status4k: 1,
|
||||
createdAt: '2022-08-18T18:11:13.000Z',
|
||||
updatedAt: '2022-08-18T18:11:13.000Z',
|
||||
lastSeasonChange: '2022-08-18T18:11:13.000Z',
|
||||
mediaAddedAt: null,
|
||||
serviceId: null,
|
||||
serviceId4k: null,
|
||||
externalServiceId: null,
|
||||
externalServiceId4k: null,
|
||||
externalServiceSlug: null,
|
||||
externalServiceSlug4k: null,
|
||||
ratingKey: null,
|
||||
ratingKey4k: null,
|
||||
},
|
||||
seasons: [],
|
||||
modifiedBy: null,
|
||||
requestedBy: {
|
||||
permissions: 4194336,
|
||||
id: 18,
|
||||
email: 'friend@seerr.dev',
|
||||
plexUsername: null,
|
||||
username: '',
|
||||
recoveryLinkExpirationDate: null,
|
||||
userType: 2,
|
||||
avatar:
|
||||
'https://gravatar.com/avatar/c77fdc27cab83732b8623d2ea873d330?default=mm&size=200',
|
||||
movieQuotaLimit: null,
|
||||
movieQuotaDays: null,
|
||||
tvQuotaLimit: null,
|
||||
tvQuotaDays: null,
|
||||
createdAt: '2022-08-17T04:55:28.000Z',
|
||||
updatedAt: '2022-08-17T04:55:28.000Z',
|
||||
requestCount: 1,
|
||||
displayName: 'friend@seerr.dev',
|
||||
},
|
||||
seasonCount: 0,
|
||||
},
|
||||
],
|
||||
}).as('getRequests');
|
||||
|
||||
cy.visit('/');
|
||||
cy.wait('@getRequests');
|
||||
cy.contains('.slider-header', 'Recent Requests')
|
||||
.next('[data-testid=media-slider]')
|
||||
.find('[data-testid=request-card]')
|
||||
.first()
|
||||
.find('[data-testid=request-card-title]')
|
||||
.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);
|
||||
});
|
||||
});
|
||||
});
|
||||
13
cypress/e2e/login.cy.ts
Normal file
13
cypress/e2e/login.cy.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
describe('Login Page', () => {
|
||||
it('succesfully logs in as an admin', () => {
|
||||
cy.loginAsAdmin();
|
||||
cy.visit('/');
|
||||
cy.contains('Trending');
|
||||
});
|
||||
|
||||
it('succesfully logs in as a local user', () => {
|
||||
cy.loginAsUser();
|
||||
cy.visit('/');
|
||||
cy.contains('Trending');
|
||||
});
|
||||
});
|
||||
12
cypress/e2e/movie-details.cy.ts
Normal file
12
cypress/e2e/movie-details.cy.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
describe('Movie Details', () => {
|
||||
it('loads a movie page', () => {
|
||||
cy.loginAsAdmin();
|
||||
// Try to load minions: rise of gru
|
||||
cy.visit('/movie/438148');
|
||||
|
||||
cy.get('[data-testid=media-title]').should(
|
||||
'contain',
|
||||
'Minions: The Rise of Gru (2022)'
|
||||
);
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
32
cypress/e2e/settings/general-settings.cy.ts
Normal file
32
cypress/e2e/settings/general-settings.cy.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
describe('General Settings', () => {
|
||||
beforeEach(() => {
|
||||
cy.loginAsAdmin();
|
||||
});
|
||||
|
||||
it('opens the settings page from the home page', () => {
|
||||
cy.visit('/');
|
||||
|
||||
cy.get('[data-testid=sidebar-toggle]').click();
|
||||
cy.get('[data-testid=sidebar-menu-settings-mobile]').click();
|
||||
|
||||
cy.get('.heading').should('contain', 'General Settings');
|
||||
});
|
||||
|
||||
it('modifies setting that requires restart', () => {
|
||||
cy.visit('/settings');
|
||||
|
||||
cy.get('#trustProxy').click();
|
||||
cy.get('[data-testid=settings-main-form]').submit();
|
||||
cy.get('[data-testid=modal-title]').should(
|
||||
'contain',
|
||||
'Server Restart Required'
|
||||
);
|
||||
|
||||
cy.get('[data-testid=modal-ok-button]').click();
|
||||
cy.get('[data-testid=modal-title]').should('not.exist');
|
||||
|
||||
cy.get('[type=checkbox]#trustProxy').click();
|
||||
cy.get('[data-testid=settings-main-form]').submit();
|
||||
cy.get('[data-testid=modal-title]').should('not.exist');
|
||||
});
|
||||
});
|
||||
28
cypress/e2e/tv-details.cy.ts
Normal file
28
cypress/e2e/tv-details.cy.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
describe('TV Details', () => {
|
||||
it('loads a tv details page', () => {
|
||||
cy.loginAsAdmin();
|
||||
// Try to load stranger things
|
||||
cy.visit('/tv/66732');
|
||||
|
||||
cy.get('[data-testid=media-title]').should(
|
||||
'contain',
|
||||
'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);
|
||||
});
|
||||
});
|
||||
});
|
||||
70
cypress/e2e/user/user-list.cy.ts
Normal file
70
cypress/e2e/user/user-list.cy.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
const testUser = {
|
||||
displayName: 'Test User',
|
||||
emailAddress: 'test@seeerr.dev',
|
||||
password: 'test1234',
|
||||
};
|
||||
|
||||
describe('User List', () => {
|
||||
beforeEach(() => {
|
||||
cy.loginAsAdmin();
|
||||
});
|
||||
|
||||
it('opens the user list from the home page', () => {
|
||||
cy.visit('/');
|
||||
|
||||
cy.get('[data-testid=sidebar-toggle]').click();
|
||||
cy.get('[data-testid=sidebar-menu-users-mobile]').click();
|
||||
|
||||
cy.get('[data-testid=page-header]').should('contain', 'User List');
|
||||
});
|
||||
|
||||
it('can find the admin user and friend user in the user list', () => {
|
||||
cy.visit('/users');
|
||||
|
||||
cy.get('[data-testid=user-list-row]').contains(Cypress.env('ADMIN_EMAIL'));
|
||||
cy.get('[data-testid=user-list-row]').contains(Cypress.env('USER_EMAIL'));
|
||||
});
|
||||
|
||||
it('can create a local user', () => {
|
||||
cy.visit('/users');
|
||||
|
||||
cy.contains('Create Local User').click();
|
||||
|
||||
cy.get('[data-testid=modal-title]').should('contain', 'Create Local User');
|
||||
|
||||
cy.get('#displayName').type(testUser.displayName);
|
||||
cy.get('#email').type(testUser.emailAddress);
|
||||
cy.get('#password').type(testUser.password);
|
||||
|
||||
cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user');
|
||||
|
||||
cy.get('[data-testid=modal-ok-button]').click();
|
||||
|
||||
cy.wait('@user');
|
||||
// Wait a little longer for the user list to fully re-render
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get('[data-testid=user-list-row]').contains(testUser.emailAddress);
|
||||
});
|
||||
|
||||
it('can delete the created local test user', () => {
|
||||
cy.visit('/users');
|
||||
|
||||
cy.contains('[data-testid=user-list-row]', testUser.emailAddress)
|
||||
.contains('Delete')
|
||||
.click();
|
||||
|
||||
cy.get('[data-testid=modal-title]').should('contain', `Delete User`);
|
||||
|
||||
cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user');
|
||||
|
||||
cy.get('[data-testid=modal-ok-button]').should('contain', 'Delete').click();
|
||||
|
||||
cy.wait('@user');
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get('[data-testid=user-list-row]')
|
||||
.contains(testUser.emailAddress)
|
||||
.should('not.exist');
|
||||
});
|
||||
});
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
35
cypress/support/commands.ts
Normal file
35
cypress/support/commands.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/// <reference types="cypress" />
|
||||
import 'cy-mobile-commands';
|
||||
|
||||
Cypress.Commands.add('login', (email, password) => {
|
||||
cy.session(
|
||||
[email, password],
|
||||
() => {
|
||||
cy.visit('/login');
|
||||
cy.contains('Use your Overseerr account').click();
|
||||
|
||||
cy.get('[data-testid=email]').type(email);
|
||||
cy.get('[data-testid=password]').type(password);
|
||||
|
||||
cy.intercept('/api/v1/auth/local').as('localLogin');
|
||||
cy.get('[data-testid=local-signin-button]').click();
|
||||
|
||||
cy.wait('@localLogin');
|
||||
|
||||
cy.url().should('contain', '/');
|
||||
},
|
||||
{
|
||||
validate() {
|
||||
cy.request('/api/v1/auth/me').its('status').should('eq', 200);
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
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'));
|
||||
});
|
||||
7
cypress/support/e2e.ts
Normal file
7
cypress/support/e2e.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import './commands';
|
||||
|
||||
before(() => {
|
||||
if (Cypress.env('SEED_DATABASE')) {
|
||||
cy.exec('yarn cypress:prepare');
|
||||
}
|
||||
});
|
||||
14
cypress/support/index.ts
Normal file
14
cypress/support/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/* eslint-disable @typescript-eslint/no-namespace */
|
||||
/// <reference types="cypress" />
|
||||
|
||||
declare global {
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
login(email?: string, password?: string): Chainable<Element>;
|
||||
loginAsAdmin(): Chainable<Element>;
|
||||
loginAsUser(): Chainable<Element>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
10
cypress/tsconfig.json
Normal file
10
cypress/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["es5", "dom"],
|
||||
"types": ["cypress", "node"],
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
@@ -8,7 +8,7 @@ To use Fail2ban with Overseerr, create a new file named `overseerr.local` in you
|
||||
|
||||
```
|
||||
[Definition]
|
||||
failregex = .*\[info\]\[Auth\]\: 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="/login"' 'href="/$app/login"';
|
||||
sub_filter 'href:"/"' 'href:"/$app"';
|
||||
sub_filter '\/_next' '\/$app\/_next';
|
||||
sub_filter '/_next' '/$app/_next';
|
||||
sub_filter '/api/v1' '/$app/api/v1';
|
||||
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- [LunaSea](https://docs.lunasea.app/modules/overseerr), a self-hosted controller for mobile and macOS
|
||||
- [Requestrr](https://github.com/darkalfx/requestrr/wiki/Configuring-Overseerr), a Discord chatbot
|
||||
- [Doplarr](https://github.com/kiranshila/Doplarr), a Discord request bot
|
||||
- [Overseerr Assistant](https://github.com/RemiRigal/Overseerr-Assistant), a browser extension for requesting directly from TMDb and IMDb
|
||||
- [Overseerr Assistant](https://github.com/RemiRigal/Overseerr-Assistant), a browser extension for requesting directly from TMDB and IMDb
|
||||
- [ha-overseerr](https://github.com/vaparr/ha-overseerr), a custom Home Assistant component
|
||||
- [OverCLIrr](https://github.com/WillFantom/OverCLIrr), a command-line tool
|
||||
- [Overseerr Exporter](https://github.com/WillFantom/overseerr-exporter), a Prometheus exporter
|
||||
|
||||
@@ -28,6 +28,7 @@ docker run -d \
|
||||
--name overseerr \
|
||||
-e LOG_LEVEL=debug \
|
||||
-e TZ=Asia/Tokyo \
|
||||
-e PORT=5055 `#optional` \
|
||||
-p 5055:5055 \
|
||||
-v /path/to/appdata/config:/app/config \
|
||||
--restart unless-stopped \
|
||||
@@ -81,6 +82,7 @@ services:
|
||||
environment:
|
||||
- LOG_LEVEL=debug
|
||||
- TZ=Asia/Tokyo
|
||||
- PORT=5055 #optional
|
||||
ports:
|
||||
- 5055:5055
|
||||
volumes:
|
||||
@@ -88,7 +90,7 @@ services:
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Then, start all services defined in the your Compose file:
|
||||
Then, start all services defined in the Compose file:
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
{% 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.)
|
||||
|
||||
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 %}
|
||||
|
||||
## Linux
|
||||
|
||||
@@ -45,7 +45,7 @@ Overseerr currently supports the following agents:
|
||||
- New Plex TV
|
||||
- Legacy Plex TV
|
||||
- TheTVDB
|
||||
- TMDb
|
||||
- TMDB
|
||||
- [HAMA](https://github.com/ZeroQI/Hama.bundle)
|
||||
|
||||
Please verify that your library is using one of the agents previously listed.
|
||||
@@ -67,7 +67,7 @@ You can also perform the following to verify the media item has a GUID Overseerr
|
||||
1. Go to the media item in Plex and **"Get info"** and click on **"View XML"**.
|
||||
2. Verify that the media item's GUID follows one of the below formats:
|
||||
|
||||
1. TMDb agent `guid="com.plexapp.agents.themoviedb://1705"`
|
||||
1. TMDB agent `guid="com.plexapp.agents.themoviedb://1705"`
|
||||
2. New Plex Movie agent `<Guid id="tmdb://464052"/>`
|
||||
3. TheTVDB agent `guid="com.plexapp.agents.thetvdb://78874/1/1"`
|
||||
4. Legacy Plex Movie agent `guid="com.plexapp.agents.imdb://tt0765446"`
|
||||
|
||||
@@ -81,7 +81,7 @@ These following special variables are only included in media-related notificatio
|
||||
| Variable | Value |
|
||||
| -------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `{{media_type}}` | The media type (`movie` or `tv`) |
|
||||
| `{{media_tmdbid}}` | The media's TMDb ID |
|
||||
| `{{media_tmdbid}}` | The media's TMDB ID |
|
||||
| `{{media_tvdbid}}` | The media's TheTVDB ID |
|
||||
| `{{media_status}}` | The media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
|
||||
| `{{media_status4k}}` | The media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
|
||||
|
||||
@@ -40,6 +40,14 @@ If you enable this setting and find yourself unable to access Overseerr, you can
|
||||
|
||||
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
|
||||
|
||||
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;
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @type {import('next').NextConfig}
|
||||
*/
|
||||
module.exports = {
|
||||
env: {
|
||||
commitTag: process.env.COMMIT_TAG || 'local',
|
||||
@@ -14,4 +17,8 @@ module.exports = {
|
||||
|
||||
return config;
|
||||
},
|
||||
experimental: {
|
||||
scrollRestoration: true,
|
||||
largePageDataBytes: 256000,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -26,6 +26,8 @@ tags:
|
||||
description: Endpoints related to retrieving movies and their details.
|
||||
- name: tv
|
||||
description: Endpoints related to retrieving TV series and their details.
|
||||
- name: other
|
||||
description: Endpoints related to other TMDB data
|
||||
- name: person
|
||||
description: Endpoints related to retrieving person details.
|
||||
- name: media
|
||||
@@ -600,6 +602,17 @@ components:
|
||||
name:
|
||||
type: string
|
||||
example: Adventure
|
||||
Company:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
example: 1
|
||||
logo_path:
|
||||
type: string
|
||||
nullable: true
|
||||
name:
|
||||
type: string
|
||||
ProductionCompany:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1039,6 +1052,8 @@ components:
|
||||
nullable: true
|
||||
status:
|
||||
type: number
|
||||
example: 0
|
||||
description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`
|
||||
requests:
|
||||
type: array
|
||||
readOnly: true
|
||||
@@ -1780,6 +1795,40 @@ components:
|
||||
message:
|
||||
type: string
|
||||
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:
|
||||
cookieAuth:
|
||||
type: apiKey
|
||||
@@ -1793,14 +1842,14 @@ components:
|
||||
paths:
|
||||
/status:
|
||||
get:
|
||||
summary: Get Overseerr version
|
||||
description: Returns the current Overseerr version in a JSON object.
|
||||
summary: Get Overseerr status
|
||||
description: Returns the current Overseerr status in a JSON object.
|
||||
security: []
|
||||
tags:
|
||||
- public
|
||||
responses:
|
||||
'200':
|
||||
description: Returned version
|
||||
description: Returned status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -1811,6 +1860,12 @@ paths:
|
||||
example: 1.0.0
|
||||
commitTag:
|
||||
type: string
|
||||
updateAvailable:
|
||||
type: boolean
|
||||
commitsBehind:
|
||||
type: number
|
||||
restartRequired:
|
||||
type: boolean
|
||||
/status/appdata:
|
||||
get:
|
||||
summary: Get application data volume status
|
||||
@@ -2469,29 +2524,44 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
example: cache-id
|
||||
name:
|
||||
type: string
|
||||
example: cache name
|
||||
stats:
|
||||
type: object
|
||||
properties:
|
||||
imageCache:
|
||||
type: object
|
||||
properties:
|
||||
tmdb:
|
||||
type: object
|
||||
properties:
|
||||
size:
|
||||
type: number
|
||||
example: 123456
|
||||
imageCount:
|
||||
type: number
|
||||
example: 123
|
||||
apiCaches:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
hits:
|
||||
type: number
|
||||
misses:
|
||||
type: number
|
||||
keys:
|
||||
type: number
|
||||
ksize:
|
||||
type: number
|
||||
vsize:
|
||||
type: number
|
||||
id:
|
||||
type: string
|
||||
example: cache-id
|
||||
name:
|
||||
type: string
|
||||
example: cache name
|
||||
stats:
|
||||
type: object
|
||||
properties:
|
||||
hits:
|
||||
type: number
|
||||
misses:
|
||||
type: number
|
||||
keys:
|
||||
type: number
|
||||
ksize:
|
||||
type: number
|
||||
vsize:
|
||||
type: number
|
||||
/settings/cache/{cacheId}/flush:
|
||||
post:
|
||||
summary: Flush a specific cache
|
||||
@@ -2533,6 +2603,12 @@ paths:
|
||||
nullable: true
|
||||
enum: [debug, info, warn, error]
|
||||
default: debug
|
||||
- in: query
|
||||
name: search
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
example: plex
|
||||
responses:
|
||||
'200':
|
||||
description: Server log returned
|
||||
@@ -3015,6 +3091,133 @@ paths:
|
||||
responses:
|
||||
'204':
|
||||
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
|
||||
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:
|
||||
get:
|
||||
summary: Get server stats
|
||||
@@ -3170,8 +3373,8 @@ paths:
|
||||
name: guid
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
type: string
|
||||
example: '9afef5a7-ec89-4d5f-9397-261e96970b50'
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
@@ -3412,7 +3615,7 @@ paths:
|
||||
$ref: '#/components/schemas/User'
|
||||
/user/{userId}/requests:
|
||||
get:
|
||||
summary: Get user by ID
|
||||
summary: Get requests for a specific user
|
||||
description: |
|
||||
Retrieves a user's requests in a JSON object.
|
||||
tags:
|
||||
@@ -3506,6 +3709,53 @@ paths:
|
||||
restricted:
|
||||
type: boolean
|
||||
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:
|
||||
get:
|
||||
summary: Get general settings for a user
|
||||
@@ -3788,6 +4038,86 @@ paths:
|
||||
- $ref: '#/components/schemas/MovieResult'
|
||||
- $ref: '#/components/schemas/TvResult'
|
||||
- $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:
|
||||
get:
|
||||
summary: Discover movies
|
||||
@@ -3809,13 +4139,73 @@ paths:
|
||||
- in: query
|
||||
name: genre
|
||||
schema:
|
||||
type: number
|
||||
type: string
|
||||
example: 18
|
||||
- in: query
|
||||
name: studio
|
||||
schema:
|
||||
type: number
|
||||
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:
|
||||
'200':
|
||||
description: Results
|
||||
@@ -4038,13 +4428,73 @@ paths:
|
||||
- in: query
|
||||
name: genre
|
||||
schema:
|
||||
type: number
|
||||
type: string
|
||||
example: 18
|
||||
- in: query
|
||||
name: network
|
||||
schema:
|
||||
type: number
|
||||
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:
|
||||
'200':
|
||||
description: Results
|
||||
@@ -4397,6 +4847,46 @@ paths:
|
||||
name:
|
||||
type: string
|
||||
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:
|
||||
get:
|
||||
summary: Get all requests
|
||||
@@ -4424,7 +4914,16 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
enum: [all, approved, available, pending, processing, unavailable]
|
||||
enum:
|
||||
[
|
||||
all,
|
||||
approved,
|
||||
available,
|
||||
pending,
|
||||
processing,
|
||||
unavailable,
|
||||
failed,
|
||||
]
|
||||
- in: query
|
||||
name: sort
|
||||
schema:
|
||||
@@ -4477,9 +4976,13 @@ paths:
|
||||
type: number
|
||||
example: 123
|
||||
seasons:
|
||||
type: array
|
||||
items:
|
||||
type: number
|
||||
oneOf:
|
||||
- type: array
|
||||
items:
|
||||
type: number
|
||||
minimum: 1
|
||||
- type: string
|
||||
enum: [all]
|
||||
is4k:
|
||||
type: boolean
|
||||
example: false
|
||||
@@ -4558,7 +5061,7 @@ paths:
|
||||
$ref: '#/components/schemas/MediaRequest'
|
||||
put:
|
||||
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:
|
||||
- request
|
||||
parameters:
|
||||
@@ -4569,6 +5072,37 @@ paths:
|
||||
example: '1'
|
||||
schema:
|
||||
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: 1
|
||||
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:
|
||||
'200':
|
||||
description: Succesfully updated request
|
||||
@@ -4639,7 +5173,7 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [pending, approve, decline, available]
|
||||
enum: [approve, decline]
|
||||
responses:
|
||||
'200':
|
||||
description: Request status changed
|
||||
@@ -5327,7 +5861,7 @@ paths:
|
||||
$ref: '#/components/schemas/SonarrSeries'
|
||||
/regions:
|
||||
get:
|
||||
summary: Regions supported by TMDb
|
||||
summary: Regions supported by TMDB
|
||||
description: Returns a list of regions in a JSON object.
|
||||
tags:
|
||||
- tmdb
|
||||
@@ -5349,7 +5883,7 @@ paths:
|
||||
example: United States of America
|
||||
/languages:
|
||||
get:
|
||||
summary: Languages supported by TMDb
|
||||
summary: Languages supported by TMDB
|
||||
description: Returns a list of languages in a JSON object.
|
||||
tags:
|
||||
- tmdb
|
||||
@@ -5414,7 +5948,7 @@ paths:
|
||||
$ref: '#/components/schemas/ProductionCompany'
|
||||
/genres/movie:
|
||||
get:
|
||||
summary: Get list of official TMDb movie genres
|
||||
summary: Get list of official TMDB movie genres
|
||||
description: Returns a list of genres in a JSON array.
|
||||
tags:
|
||||
- tmdb
|
||||
@@ -5442,7 +5976,7 @@ paths:
|
||||
example: Family
|
||||
/genres/tv:
|
||||
get:
|
||||
summary: Get list of official TMDb movie genres
|
||||
summary: Get list of official TMDB movie genres
|
||||
description: Returns a list of genres in a JSON array.
|
||||
tags:
|
||||
- tmdb
|
||||
@@ -5562,6 +6096,36 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Issue'
|
||||
|
||||
/issue/count:
|
||||
get:
|
||||
summary: Gets issue counts
|
||||
description: |
|
||||
Returns the number of open and closed issues, as well as the number of issues of each type.
|
||||
tags:
|
||||
- issue
|
||||
responses:
|
||||
'200':
|
||||
description: Issue counts returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
total:
|
||||
type: number
|
||||
video:
|
||||
type: number
|
||||
audio:
|
||||
type: number
|
||||
subtitles:
|
||||
type: number
|
||||
others:
|
||||
type: number
|
||||
open:
|
||||
type: number
|
||||
closed:
|
||||
type: number
|
||||
/issue/{issueId}:
|
||||
get:
|
||||
summary: Get issue
|
||||
@@ -5729,6 +6293,89 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$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:
|
||||
- cookieAuth: []
|
||||
- apiKey: []
|
||||
|
||||
285
package.json
285
package.json
@@ -1,20 +1,27 @@
|
||||
{
|
||||
"name": "overseerr",
|
||||
"version": "1.29.0",
|
||||
"version": "1.33.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node --files --project server/tsconfig.json server/index.ts",
|
||||
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates",
|
||||
"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 && tsc-alias -p server/tsconfig.json",
|
||||
"build:next": "next build",
|
||||
"build": "yarn build:next && yarn build:server",
|
||||
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\"",
|
||||
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache",
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault true \"./src/**/!(*.test).{ts,tsx}\"",
|
||||
"migration:generate": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate",
|
||||
"migration:create": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create",
|
||||
"migration:run": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run",
|
||||
"format": "prettier --write .",
|
||||
"prepare": "husky install"
|
||||
"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 -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -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:check": "prettier --check --cache .",
|
||||
"typecheck": "yarn typecheck:server && yarn typecheck:client",
|
||||
"typecheck:server": "tsc --project server/tsconfig.json --noEmit",
|
||||
"typecheck:client": "tsc --noEmit",
|
||||
"prepare": "husky install",
|
||||
"cypress:open": "cypress open",
|
||||
"cypress:prepare": "ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/scripts/prepareTestDb.ts",
|
||||
"cypress:build": "yarn build && yarn cypress:prepare"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -22,127 +29,147 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.5.0",
|
||||
"@heroicons/react": "^1.0.6",
|
||||
"@supercharge/request-ip": "^1.2.0",
|
||||
"@svgr/webpack": "^6.2.1",
|
||||
"@tanem/react-nprogress": "^4.0.10",
|
||||
"ace-builds": "^1.4.14",
|
||||
"axios": "^0.26.1",
|
||||
"bcrypt": "^5.0.1",
|
||||
"bowser": "^2.11.0",
|
||||
"connect-typeorm": "^1.1.4",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
"country-flag-icons": "^1.4.21",
|
||||
"csurf": "^1.11.0",
|
||||
"email-templates": "^8.0.10",
|
||||
"express": "^4.17.3",
|
||||
"express-openapi-validator": "^4.13.6",
|
||||
"express-rate-limit": "^6.3.0",
|
||||
"express-session": "^1.17.2",
|
||||
"formik": "^2.2.9",
|
||||
"gravatar-url": "^3.1.0",
|
||||
"intl": "^1.2.5",
|
||||
"lodash": "^4.17.21",
|
||||
"next": "12.1.0",
|
||||
"node-cache": "^5.1.2",
|
||||
"node-gyp": "^9.0.0",
|
||||
"node-schedule": "^2.1.0",
|
||||
"nodemailer": "^6.7.2",
|
||||
"openpgp": "^5.2.0",
|
||||
"plex-api": "^5.3.2",
|
||||
"pug": "^3.0.2",
|
||||
"react": "17.0.2",
|
||||
"react-ace": "^9.5.0",
|
||||
"react-animate-height": "^2.0.23",
|
||||
"react-dom": "17.0.2",
|
||||
"react-intersection-observer": "^8.33.1",
|
||||
"react-intl": "5.24.7",
|
||||
"react-markdown": "^8.0.0",
|
||||
"react-select": "^5.2.2",
|
||||
"react-spring": "^9.4.4",
|
||||
"react-toast-notifications": "^2.5.1",
|
||||
"react-transition-group": "^4.4.2",
|
||||
"react-truncate-markup": "^5.1.0",
|
||||
"react-use-clipboard": "1.0.7",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"secure-random-password": "^0.2.3",
|
||||
"semver": "^7.3.5",
|
||||
"sqlite3": "^5.0.2",
|
||||
"swagger-ui-express": "^4.3.0",
|
||||
"swr": "^1.2.2",
|
||||
"typeorm": "0.2.45",
|
||||
"web-push": "^3.4.5",
|
||||
"winston": "^3.6.0",
|
||||
"winston-daily-rotate-file": "^4.6.1",
|
||||
"xml2js": "^0.4.23",
|
||||
"yamljs": "^0.3.0",
|
||||
"yup": "^0.32.11"
|
||||
"@formatjs/intl-displaynames": "6.2.6",
|
||||
"@formatjs/intl-locale": "3.1.1",
|
||||
"@formatjs/intl-pluralrules": "5.1.10",
|
||||
"@formatjs/intl-utils": "3.8.4",
|
||||
"@headlessui/react": "1.7.12",
|
||||
"@heroicons/react": "2.0.16",
|
||||
"@supercharge/request-ip": "1.2.0",
|
||||
"@svgr/webpack": "6.5.1",
|
||||
"@tanem/react-nprogress": "5.0.30",
|
||||
"ace-builds": "1.15.2",
|
||||
"axios": "1.3.4",
|
||||
"axios-rate-limit": "1.3.0",
|
||||
"bcrypt": "5.1.0",
|
||||
"bowser": "2.11.0",
|
||||
"connect-typeorm": "1.1.4",
|
||||
"cookie-parser": "1.4.6",
|
||||
"copy-to-clipboard": "3.3.3",
|
||||
"country-flag-icons": "1.5.5",
|
||||
"cronstrue": "2.23.0",
|
||||
"csurf": "1.11.0",
|
||||
"date-fns": "2.29.3",
|
||||
"dayjs": "1.11.7",
|
||||
"email-templates": "9.0.0",
|
||||
"express": "4.18.2",
|
||||
"express-openapi-validator": "4.13.8",
|
||||
"express-rate-limit": "6.7.0",
|
||||
"express-session": "1.17.3",
|
||||
"formik": "2.2.9",
|
||||
"gravatar-url": "3.1.0",
|
||||
"intl": "1.2.5",
|
||||
"lodash": "4.17.21",
|
||||
"next": "12.3.4",
|
||||
"node-cache": "5.1.2",
|
||||
"node-gyp": "9.3.1",
|
||||
"node-schedule": "2.1.1",
|
||||
"nodemailer": "6.9.1",
|
||||
"openpgp": "5.7.0",
|
||||
"plex-api": "5.3.2",
|
||||
"pug": "3.0.2",
|
||||
"react": "18.2.0",
|
||||
"react-ace": "10.1.0",
|
||||
"react-animate-height": "2.1.2",
|
||||
"react-aria": "3.23.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-intersection-observer": "9.4.3",
|
||||
"react-intl": "6.2.10",
|
||||
"react-markdown": "8.0.5",
|
||||
"react-popper-tooltip": "4.4.2",
|
||||
"react-select": "5.7.0",
|
||||
"react-spring": "9.7.1",
|
||||
"react-tailwindcss-datepicker-sct": "1.3.4",
|
||||
"react-toast-notifications": "2.5.1",
|
||||
"react-truncate-markup": "5.1.2",
|
||||
"react-use-clipboard": "1.0.9",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"secure-random-password": "0.2.3",
|
||||
"semver": "7.3.8",
|
||||
"sqlite3": "5.1.4",
|
||||
"swagger-ui-express": "4.6.2",
|
||||
"swr": "2.0.4",
|
||||
"typeorm": "0.3.12",
|
||||
"web-push": "3.5.0",
|
||||
"winston": "3.8.2",
|
||||
"winston-daily-rotate-file": "4.7.1",
|
||||
"xml2js": "0.4.23",
|
||||
"yamljs": "0.3.0",
|
||||
"yup": "0.32.11",
|
||||
"zod": "3.20.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.17.6",
|
||||
"@commitlint/cli": "^16.2.1",
|
||||
"@commitlint/config-conventional": "^16.2.1",
|
||||
"@semantic-release/changelog": "^6.0.1",
|
||||
"@semantic-release/commit-analyzer": "^9.0.2",
|
||||
"@semantic-release/exec": "^6.0.3",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@tailwindcss/aspect-ratio": "^0.4.0",
|
||||
"@tailwindcss/forms": "^0.5.0",
|
||||
"@tailwindcss/typography": "^0.5.2",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
"@types/country-flag-icons": "^1.2.0",
|
||||
"@types/csurf": "^1.11.2",
|
||||
"@types/email-templates": "^8.0.4",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/express-session": "^1.17.4",
|
||||
"@types/lodash": "^4.14.179",
|
||||
"@types/node": "^17.0.21",
|
||||
"@types/node-schedule": "^1.3.2",
|
||||
"@types/nodemailer": "^6.4.4",
|
||||
"@types/react": "^17.0.40",
|
||||
"@types/react-dom": "^17.0.13",
|
||||
"@types/react-transition-group": "^4.4.4",
|
||||
"@types/secure-random-password": "^0.2.1",
|
||||
"@types/semver": "^7.3.9",
|
||||
"@types/swagger-ui-express": "^4.1.3",
|
||||
"@types/web-push": "^3.3.2",
|
||||
"@types/xml2js": "^0.4.9",
|
||||
"@types/yamljs": "^0.2.31",
|
||||
"@types/yup": "^0.29.13",
|
||||
"@typescript-eslint/eslint-plugin": "^5.14.0",
|
||||
"@typescript-eslint/parser": "^5.14.0",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"babel-plugin-react-intl": "^8.2.25",
|
||||
"babel-plugin-react-intl-auto": "^3.3.0",
|
||||
"commitizen": "^4.2.4",
|
||||
"copyfiles": "^2.4.1",
|
||||
"cz-conventional-changelog": "^3.3.0",
|
||||
"eslint": "^8.11.0",
|
||||
"eslint-config-next": "^12.1.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-formatjs": "^3.0.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-react": "^7.29.3",
|
||||
"eslint-plugin-react-hooks": "^4.3.0",
|
||||
"extract-react-intl-messages": "^4.1.1",
|
||||
"husky": "^7.0.4",
|
||||
"lint-staged": "^12.3.5",
|
||||
"nodemon": "^2.0.15",
|
||||
"postcss": "^8.4.8",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier-plugin-tailwindcss": "^0.1.8",
|
||||
"semantic-release": "^19.0.2",
|
||||
"semantic-release-docker-buildx": "^1.0.1",
|
||||
"tailwindcss": "^3.0.23",
|
||||
"ts-node": "^10.7.0",
|
||||
"typescript": "^4.6.2"
|
||||
"@babel/cli": "7.21.0",
|
||||
"@commitlint/cli": "17.4.4",
|
||||
"@commitlint/config-conventional": "17.4.4",
|
||||
"@semantic-release/changelog": "6.0.2",
|
||||
"@semantic-release/commit-analyzer": "9.0.2",
|
||||
"@semantic-release/exec": "6.0.3",
|
||||
"@semantic-release/git": "10.0.1",
|
||||
"@tailwindcss/aspect-ratio": "0.4.2",
|
||||
"@tailwindcss/forms": "0.5.3",
|
||||
"@tailwindcss/typography": "0.5.9",
|
||||
"@types/bcrypt": "5.0.0",
|
||||
"@types/cookie-parser": "1.4.3",
|
||||
"@types/country-flag-icons": "1.2.0",
|
||||
"@types/csurf": "1.11.2",
|
||||
"@types/email-templates": "8.0.4",
|
||||
"@types/express": "4.17.17",
|
||||
"@types/express-session": "1.17.6",
|
||||
"@types/lodash": "4.14.191",
|
||||
"@types/node": "17.0.36",
|
||||
"@types/node-schedule": "2.1.0",
|
||||
"@types/nodemailer": "6.4.7",
|
||||
"@types/react": "18.0.28",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@types/react-transition-group": "4.4.5",
|
||||
"@types/secure-random-password": "0.2.1",
|
||||
"@types/semver": "7.3.13",
|
||||
"@types/swagger-ui-express": "4.1.3",
|
||||
"@types/web-push": "3.3.2",
|
||||
"@types/xml2js": "0.4.11",
|
||||
"@types/yamljs": "0.2.31",
|
||||
"@types/yup": "0.29.14",
|
||||
"@typescript-eslint/eslint-plugin": "5.54.0",
|
||||
"@typescript-eslint/parser": "5.54.0",
|
||||
"autoprefixer": "10.4.13",
|
||||
"babel-plugin-react-intl": "8.2.25",
|
||||
"babel-plugin-react-intl-auto": "3.3.0",
|
||||
"commitizen": "4.3.0",
|
||||
"copyfiles": "2.4.1",
|
||||
"cy-mobile-commands": "0.3.0",
|
||||
"cypress": "12.7.0",
|
||||
"cz-conventional-changelog": "3.3.0",
|
||||
"eslint": "8.35.0",
|
||||
"eslint-config-next": "12.3.4",
|
||||
"eslint-config-prettier": "8.6.0",
|
||||
"eslint-plugin-formatjs": "4.9.0",
|
||||
"eslint-plugin-jsx-a11y": "6.7.1",
|
||||
"eslint-plugin-no-relative-import-paths": "1.5.2",
|
||||
"eslint-plugin-prettier": "4.2.1",
|
||||
"eslint-plugin-react": "7.32.2",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"extract-react-intl-messages": "4.1.1",
|
||||
"husky": "8.0.3",
|
||||
"lint-staged": "13.1.2",
|
||||
"nodemon": "2.0.20",
|
||||
"postcss": "8.4.21",
|
||||
"prettier": "2.8.4",
|
||||
"prettier-plugin-organize-imports": "3.2.2",
|
||||
"prettier-plugin-tailwindcss": "0.2.3",
|
||||
"semantic-release": "19.0.5",
|
||||
"semantic-release-docker-buildx": "1.0.1",
|
||||
"tailwindcss": "3.2.7",
|
||||
"ts-node": "10.9.1",
|
||||
"tsc-alias": "1.8.2",
|
||||
"tsconfig-paths": "4.1.2",
|
||||
"typescript": "4.9.5"
|
||||
},
|
||||
"resolutions": {
|
||||
"sqlite3/node-gyp": "^8.4.1"
|
||||
"sqlite3/node-gyp": "8.4.1",
|
||||
"@types/react": "18.0.28",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@types/express-session": "1.17.6"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
@@ -163,10 +190,6 @@
|
||||
"@commitlint/config-conventional"
|
||||
]
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5"
|
||||
},
|
||||
"release": {
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer",
|
||||
@@ -204,7 +227,7 @@
|
||||
{
|
||||
"path": "semantic-release-docker-buildx",
|
||||
"buildArgs": {
|
||||
"COMMIT_TAG": "$GITHUB_SHA"
|
||||
"COMMIT_TAG": "$GIT_SHA"
|
||||
},
|
||||
"imageNames": [
|
||||
"sctx/overseerr",
|
||||
|
||||
21
renovate.json
Normal file
21
renovate.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:js-app",
|
||||
"group:allNonMajor",
|
||||
"docker:disableMajor",
|
||||
"helpers:disableTypesNodeMajor"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchManagers": ["github-actions"],
|
||||
"groupName": "GitHub Actions",
|
||||
"groupSlug": "github-actions"
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["node"],
|
||||
"groupName": "Node.js",
|
||||
"groupSlug": "node"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import xml2js from 'xml2js';
|
||||
import fs, { promises as fsp } from 'fs';
|
||||
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
|
||||
// originally at https://raw.githubusercontent.com/ScudLee/anime-lists/master/anime-list.xml
|
||||
@@ -14,7 +14,7 @@ const LOCAL_PATH = process.env.CONFIG_DIRECTORY
|
||||
|
||||
const mappingRegexp = new RegExp(/;[0-9]+-([0-9]+)/g);
|
||||
|
||||
// Anime-List xml files are community maintained mappings that Hama agent uses to map AniDB IDs to TVDB/TMDb IDs
|
||||
// Anime-List xml files are community maintained mappings that Hama agent uses to map AniDB IDs to TVDB/TMDB IDs
|
||||
// https://github.com/Anime-Lists/anime-lists/
|
||||
|
||||
interface AnimeMapping {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||
import NodeCache from 'node-cache';
|
||||
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||
import axios from 'axios';
|
||||
import rateLimit from 'axios-rate-limit';
|
||||
import type NodeCache from 'node-cache';
|
||||
|
||||
// 5 minute default TTL (in seconds)
|
||||
const DEFAULT_TTL = 300;
|
||||
@@ -10,6 +12,10 @@ const DEFAULT_ROLLING_BUFFER = 10000;
|
||||
interface ExternalAPIOptions {
|
||||
nodeCache?: NodeCache;
|
||||
headers?: Record<string, unknown>;
|
||||
rateLimit?: {
|
||||
maxRPS: number;
|
||||
maxRequests: number;
|
||||
};
|
||||
}
|
||||
|
||||
class ExternalAPI {
|
||||
@@ -31,6 +37,14 @@ class ExternalAPI {
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (options.rateLimit) {
|
||||
this.axios = rateLimit(this.axios, {
|
||||
maxRequests: options.rateLimit.maxRequests,
|
||||
maxRPS: options.rateLimit.maxRPS,
|
||||
});
|
||||
}
|
||||
|
||||
this.baseUrl = baseUrl;
|
||||
this.cache = options.nodeCache;
|
||||
}
|
||||
@@ -55,6 +69,30 @@ class ExternalAPI {
|
||||
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>(
|
||||
endpoint: string,
|
||||
config?: AxiosRequestConfig,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import cacheManager from '../lib/cache';
|
||||
import logger from '../logger';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import logger from '@server/logger';
|
||||
import ExternalAPI from './externalapi';
|
||||
|
||||
interface GitHubRelease {
|
||||
|
||||
@@ -1,6 +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 { getSettings, Library, PlexSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
|
||||
export interface PlexLibraryItem {
|
||||
ratingKey: string;
|
||||
@@ -130,7 +131,6 @@ class PlexAPI {
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
public async getStatus() {
|
||||
return await this.plexClient.query('/');
|
||||
}
|
||||
@@ -226,12 +226,17 @@ class PlexAPI {
|
||||
id: string,
|
||||
options: { addedAt: number } = {
|
||||
addedAt: Date.now() - 1000 * 60 * 60,
|
||||
}
|
||||
},
|
||||
mediaType: 'movie' | 'show'
|
||||
): Promise<PlexLibraryItem[]> {
|
||||
const response = await this.plexClient.query<PlexLibraryResponse>({
|
||||
uri: `/library/sections/${id}/all?sort=addedAt%3Adesc&addedAt>>=${Math.floor(
|
||||
options.addedAt / 1000
|
||||
)}`,
|
||||
uri: `/library/sections/${id}/all?type=${
|
||||
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;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import xml2js from 'xml2js';
|
||||
import { PlexDevice } from '../interfaces/api/plexInterfaces';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import ExternalAPI from './externalapi';
|
||||
|
||||
interface PlexAccountResponse {
|
||||
user: PlexUser;
|
||||
@@ -81,21 +82,6 @@ interface ServerResponse {
|
||||
};
|
||||
}
|
||||
|
||||
interface FriendResponse {
|
||||
MediaContainer: {
|
||||
User: {
|
||||
$: {
|
||||
id: string;
|
||||
title: string;
|
||||
username: string;
|
||||
email: string;
|
||||
thumb: string;
|
||||
};
|
||||
Server?: ServerResponse[];
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
interface UsersResponse {
|
||||
MediaContainer: {
|
||||
User: {
|
||||
@@ -111,20 +97,54 @@ 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;
|
||||
}
|
||||
|
||||
class PlexTvAPI extends ExternalAPI {
|
||||
private authToken: string;
|
||||
private axios: AxiosInstance;
|
||||
|
||||
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.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[]> {
|
||||
@@ -199,19 +219,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> {
|
||||
const settings = getSettings();
|
||||
|
||||
@@ -220,9 +227,9 @@ class PlexTvAPI {
|
||||
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);
|
||||
|
||||
@@ -252,6 +259,83 @@ class PlexTvAPI {
|
||||
)) as UsersResponse;
|
||||
return parsedXml;
|
||||
}
|
||||
|
||||
public async getWatchlist({
|
||||
offset = 0,
|
||||
size = 20,
|
||||
}: { offset?: number; size?: number } = {}): Promise<{
|
||||
offset: number;
|
||||
size: number;
|
||||
totalSize: number;
|
||||
items: PlexWatchlistItem[];
|
||||
}> {
|
||||
try {
|
||||
const response = await this.axios.get<WatchlistResponse>(
|
||||
'/library/sections/watchlist/all',
|
||||
{
|
||||
params: {
|
||||
'X-Plex-Container-Start': offset,
|
||||
'X-Plex-Container-Size': size,
|
||||
},
|
||||
baseURL: 'https://metadata.provider.plex.tv',
|
||||
}
|
||||
);
|
||||
|
||||
const watchlistDetails = await Promise.all(
|
||||
(response.data.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: response.data.MediaContainer.totalSize,
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PlexTvAPI;
|
||||
|
||||
@@ -1,28 +1,40 @@
|
||||
import cacheManager from '../lib/cache';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import ExternalAPI from './externalapi';
|
||||
|
||||
interface RTSearchResult {
|
||||
meterClass: 'certified_fresh' | 'fresh' | 'rotten';
|
||||
meterScore: number;
|
||||
url: string;
|
||||
interface RTAlgoliaSearchResponse {
|
||||
results: {
|
||||
hits: RTAlgoliaHit[];
|
||||
index: 'content_rt' | 'people_rt';
|
||||
}[];
|
||||
}
|
||||
|
||||
interface RTTvSearchResult extends RTSearchResult {
|
||||
interface RTAlgoliaHit {
|
||||
emsId: string;
|
||||
emsVersionId: string;
|
||||
tmsId: string;
|
||||
type: string;
|
||||
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[];
|
||||
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 {
|
||||
@@ -47,13 +59,20 @@ export interface RTRating {
|
||||
*/
|
||||
class RottenTomatoes extends ExternalAPI {
|
||||
constructor() {
|
||||
const settings = getSettings();
|
||||
super(
|
||||
'https://www.rottentomatoes.com/api/private',
|
||||
{},
|
||||
'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,
|
||||
}
|
||||
@@ -61,14 +80,11 @@ class RottenTomatoes extends ExternalAPI {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the 1.0 api for the movie title
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
@@ -77,30 +93,42 @@ class RottenTomatoes extends ExternalAPI {
|
||||
year: number
|
||||
): Promise<RTRating | null> {
|
||||
try {
|
||||
const data = await this.get<RTMultiSearchResponse>('/v2.0/search/', {
|
||||
params: { q: name, limit: 10 },
|
||||
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 = data.movies.find(
|
||||
(movie) => movie.year === year && movie.name === name
|
||||
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 = data.movies.find(
|
||||
(movie) => movie.year === year && movie.name.includes(name)
|
||||
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 = data.movies.find((movie) => movie.year === year);
|
||||
movie = contentResults.hits.find((movie) => movie.releaseYear === year);
|
||||
}
|
||||
|
||||
// One last try, try exact name match only
|
||||
if (!movie) {
|
||||
movie = data.movies.find((movie) => movie.name === name);
|
||||
movie = contentResults.hits.find((movie) => movie.title === name);
|
||||
}
|
||||
|
||||
if (!movie) {
|
||||
@@ -108,16 +136,15 @@ class RottenTomatoes extends ExternalAPI {
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
year: Number(movie.releaseYear),
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
@@ -131,14 +158,28 @@ class RottenTomatoes extends ExternalAPI {
|
||||
year?: number
|
||||
): Promise<RTRating | null> {
|
||||
try {
|
||||
const data = await this.get<RTMultiSearchResponse>('/v2.0/search/', {
|
||||
params: { q: name, limit: 10 },
|
||||
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
|
||||
requests: [
|
||||
{
|
||||
indexName: 'content_rt',
|
||||
query: name,
|
||||
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let tvshow: RTTvSearchResult | undefined = data.tvSeries[0];
|
||||
const contentResults = data.results.find((r) => r.index === 'content_rt');
|
||||
|
||||
if (!contentResults) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let tvshow: RTAlgoliaHit | undefined = contentResults.hits[0];
|
||||
|
||||
if (year) {
|
||||
tvshow = data.tvSeries.find((series) => series.startYear === year);
|
||||
tvshow = contentResults.hits.find(
|
||||
(series) => series.releaseYear === year
|
||||
);
|
||||
}
|
||||
|
||||
if (!tvshow) {
|
||||
@@ -147,10 +188,11 @@ class RottenTomatoes extends ExternalAPI {
|
||||
|
||||
return {
|
||||
title: tvshow.title,
|
||||
url: `https://www.rottentomatoes.com${tvshow.url}`,
|
||||
criticsRating: tvshow.meterClass === 'fresh' ? 'Fresh' : 'Rotten',
|
||||
criticsScore: tvshow.meterScore,
|
||||
year: tvshow.startYear,
|
||||
url: `https://www.rottentomatoes.com/tv/${tvshow.vanity}`,
|
||||
criticsRating:
|
||||
tvshow.rottenTomatoes.criticsScore >= 60 ? 'Fresh' : 'Rotten',
|
||||
criticsScore: tvshow.rottenTomatoes.criticsScore,
|
||||
year: Number(tvshow.releaseYear),
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error(`[RT API] Failed to retrieve tv ratings: ${e.message}`);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import cacheManager, { AvailableCacheIds } from '../../lib/cache';
|
||||
import { DVRSettings } from '../../lib/settings';
|
||||
import ExternalAPI from '../externalapi';
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import type { AvailableCacheIds } from '@server/lib/cache';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import type { DVRSettings } from '@server/lib/settings';
|
||||
|
||||
export interface SystemStatus {
|
||||
version: string;
|
||||
@@ -157,7 +158,12 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
||||
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
|
||||
try {
|
||||
const response = await this.axios.get<QueueResponse<QueueItemAppendT>>(
|
||||
`/queue`
|
||||
`/queue`,
|
||||
{
|
||||
params: {
|
||||
includeEpisode: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.records;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import logger from '../../logger';
|
||||
import logger from '@server/logger';
|
||||
import ServarrBase from './base';
|
||||
|
||||
export interface RadarrMovieOptions {
|
||||
@@ -69,7 +69,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
|
||||
return response.data[0];
|
||||
} catch (e) {
|
||||
logger.error('Error retrieving movie by TMDb ID', {
|
||||
logger.error('Error retrieving movie by TMDB ID', {
|
||||
label: 'Radarr API',
|
||||
errorMessage: e.message,
|
||||
tmdbId: id,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logger from '../../logger';
|
||||
import logger from '@server/logger';
|
||||
import ServarrBase from './base';
|
||||
|
||||
interface SonarrSeason {
|
||||
export interface SonarrSeason {
|
||||
seasonNumber: number;
|
||||
monitored: boolean;
|
||||
statistics?: {
|
||||
@@ -13,6 +13,21 @@ interface SonarrSeason {
|
||||
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 {
|
||||
title: string;
|
||||
@@ -61,6 +76,15 @@ export interface SonarrSeries {
|
||||
ignoreEpisodesWithoutFiles?: boolean;
|
||||
searchForMissingEpisodes?: boolean;
|
||||
};
|
||||
statistics: {
|
||||
seasonCount: number;
|
||||
episodeFileCount: number;
|
||||
episodeCount: number;
|
||||
totalEpisodeCount: number;
|
||||
sizeOnDisk: number;
|
||||
releaseGroups: string[];
|
||||
percentOfEpisodes: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AddSeriesOptions {
|
||||
@@ -82,7 +106,11 @@ export interface LanguageProfile {
|
||||
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 }) {
|
||||
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[]> {
|
||||
try {
|
||||
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
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 axios from 'axios';
|
||||
import { uniqWith } from 'lodash';
|
||||
import { User } from '../entity/User';
|
||||
import { TautulliSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
|
||||
export interface TautulliHistoryRecord {
|
||||
date: number;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import { sortBy } from 'lodash';
|
||||
import cacheManager from '../../lib/cache';
|
||||
import ExternalAPI from '../externalapi';
|
||||
import {
|
||||
import type {
|
||||
TmdbCollection,
|
||||
TmdbCompanySearchResponse,
|
||||
TmdbExternalIdResponse,
|
||||
TmdbGenre,
|
||||
TmdbGenresResult,
|
||||
TmdbKeyword,
|
||||
TmdbKeywordSearchResponse,
|
||||
TmdbLanguage,
|
||||
TmdbMovieDetails,
|
||||
TmdbNetwork,
|
||||
@@ -19,6 +22,8 @@ import {
|
||||
TmdbSeasonWithEpisodes,
|
||||
TmdbTvDetails,
|
||||
TmdbUpcomingMoviesResponse,
|
||||
TmdbWatchProviderDetails,
|
||||
TmdbWatchProviderRegion,
|
||||
} from './interfaces';
|
||||
|
||||
interface SearchOptions {
|
||||
@@ -32,30 +37,43 @@ interface SingleSearchOptions extends SearchOptions {
|
||||
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 {
|
||||
page?: number;
|
||||
includeAdult?: boolean;
|
||||
language?: string;
|
||||
primaryReleaseDateGte?: string;
|
||||
primaryReleaseDateLte?: string;
|
||||
withRuntimeGte?: string;
|
||||
withRuntimeLte?: string;
|
||||
voteAverageGte?: string;
|
||||
voteAverageLte?: string;
|
||||
voteCountGte?: string;
|
||||
voteCountLte?: string;
|
||||
originalLanguage?: string;
|
||||
genre?: number;
|
||||
studio?: number;
|
||||
sortBy?:
|
||||
| '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';
|
||||
genre?: string;
|
||||
studio?: string;
|
||||
keywords?: string;
|
||||
sortBy?: SortOptions;
|
||||
watchRegion?: string;
|
||||
watchProviders?: string;
|
||||
}
|
||||
|
||||
interface DiscoverTvOptions {
|
||||
@@ -63,19 +81,20 @@ interface DiscoverTvOptions {
|
||||
language?: string;
|
||||
firstAirDateGte?: string;
|
||||
firstAirDateLte?: string;
|
||||
withRuntimeGte?: string;
|
||||
withRuntimeLte?: string;
|
||||
voteAverageGte?: string;
|
||||
voteAverageLte?: string;
|
||||
voteCountGte?: string;
|
||||
voteCountLte?: string;
|
||||
includeEmptyReleaseDate?: boolean;
|
||||
originalLanguage?: string;
|
||||
genre?: number;
|
||||
genre?: string;
|
||||
network?: number;
|
||||
sortBy?:
|
||||
| 'popularity.asc'
|
||||
| 'popularity.desc'
|
||||
| 'vote_average.asc'
|
||||
| 'vote_average.desc'
|
||||
| 'vote_count.asc'
|
||||
| 'vote_count.desc'
|
||||
| 'first_air_date.asc'
|
||||
| 'first_air_date.desc';
|
||||
keywords?: string;
|
||||
sortBy?: SortOptions;
|
||||
watchRegion?: string;
|
||||
watchProviders?: string;
|
||||
}
|
||||
|
||||
class TheMovieDb extends ExternalAPI {
|
||||
@@ -92,6 +111,10 @@ class TheMovieDb extends ExternalAPI {
|
||||
},
|
||||
{
|
||||
nodeCache: cacheManager.getCache('tmdb').data,
|
||||
rateLimit: {
|
||||
maxRequests: 20,
|
||||
maxRPS: 50,
|
||||
},
|
||||
}
|
||||
);
|
||||
this.region = region;
|
||||
@@ -129,7 +152,13 @@ class TheMovieDb extends ExternalAPI {
|
||||
}: SingleSearchOptions): Promise<TmdbSearchMovieResponse> => {
|
||||
try {
|
||||
const data = await this.get<TmdbSearchMovieResponse>('/search/movie', {
|
||||
params: { query, page, include_adult: includeAdult, language, year },
|
||||
params: {
|
||||
query,
|
||||
page,
|
||||
include_adult: includeAdult,
|
||||
language,
|
||||
primary_release_year: year,
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
@@ -186,7 +215,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch person details: ${e.message}`);
|
||||
throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -208,7 +237,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDb] Failed to fetch person combined credits: ${e.message}`
|
||||
`[TMDB] Failed to fetch person combined credits: ${e.message}`
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -227,7 +256,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
params: {
|
||||
language,
|
||||
append_to_response:
|
||||
'credits,external_ids,videos,release_dates,watch/providers',
|
||||
'credits,external_ids,videos,keywords,release_dates,watch/providers',
|
||||
},
|
||||
},
|
||||
43200
|
||||
@@ -235,7 +264,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch movie details: ${e.message}`);
|
||||
throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -261,7 +290,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`);
|
||||
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -287,7 +316,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`);
|
||||
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -313,7 +342,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
|
||||
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,7 +368,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
|
||||
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,7 +394,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch movies by keyword: ${e.message}`);
|
||||
throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,7 +421,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDb] Failed to fetch TV recommendations: ${e.message}`
|
||||
`[TMDB] Failed to fetch TV recommendations: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -416,7 +445,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch TV similar: ${e.message}`);
|
||||
throw new Error(`[TMDB] Failed to fetch TV similar: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,8 +459,27 @@ class TheMovieDb extends ExternalAPI {
|
||||
originalLanguage,
|
||||
genre,
|
||||
studio,
|
||||
keywords,
|
||||
withRuntimeGte,
|
||||
withRuntimeLte,
|
||||
voteAverageGte,
|
||||
voteAverageLte,
|
||||
voteCountGte,
|
||||
voteCountLte,
|
||||
watchProviders,
|
||||
watchRegion,
|
||||
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
||||
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', {
|
||||
params: {
|
||||
sort_by: sortBy,
|
||||
@@ -439,17 +487,39 @@ class TheMovieDb extends ExternalAPI {
|
||||
include_adult: includeAdult,
|
||||
language,
|
||||
region: this.region,
|
||||
with_original_language: originalLanguage ?? this.originalLanguage,
|
||||
'primary_release_date.gte': primaryReleaseDateGte,
|
||||
'primary_release_date.lte': primaryReleaseDateLte,
|
||||
with_original_language:
|
||||
originalLanguage && originalLanguage !== 'all'
|
||||
? 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_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,
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
|
||||
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -463,26 +533,67 @@ class TheMovieDb extends ExternalAPI {
|
||||
originalLanguage,
|
||||
genre,
|
||||
network,
|
||||
keywords,
|
||||
withRuntimeGte,
|
||||
withRuntimeLte,
|
||||
voteAverageGte,
|
||||
voteAverageLte,
|
||||
voteCountGte,
|
||||
voteCountLte,
|
||||
watchProviders,
|
||||
watchRegion,
|
||||
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
||||
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', {
|
||||
params: {
|
||||
sort_by: sortBy,
|
||||
page,
|
||||
language,
|
||||
region: this.region,
|
||||
'first_air_date.gte': firstAirDateGte,
|
||||
'first_air_date.lte': firstAirDateLte,
|
||||
with_original_language: originalLanguage ?? 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!
|
||||
'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,
|
||||
with_genres: genre,
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch discover TV: ${e.message}`);
|
||||
throw new Error(`[TMDB] Failed to fetch discover TV: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -508,7 +619,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch upcoming movies: ${e.message}`);
|
||||
throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -535,7 +646,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
|
||||
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -558,7 +669,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
|
||||
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -581,7 +692,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
|
||||
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -613,7 +724,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to find by external ID: ${e.message}`);
|
||||
throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -651,7 +762,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
throw new Error(`No movie or show returned from API for ID ${imdbId}`);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDb] Failed to find media using external IMDb ID: ${e.message}`
|
||||
`[TMDB] Failed to find media using external IMDb ID: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -681,7 +792,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
throw new Error(`No show returned from API for ID ${tvdbId}`);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDb] Failed to get TV show using the external TVDB ID: ${e.message}`
|
||||
`[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -705,7 +816,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch collection: ${e.message}`);
|
||||
throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -721,7 +832,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return regions;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch countries: ${e.message}`);
|
||||
throw new Error(`[TMDB] Failed to fetch countries: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -737,7 +848,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return languages;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch langauges: ${e.message}`);
|
||||
throw new Error(`[TMDB] Failed to fetch langauges: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -749,7 +860,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch movie studio: ${e.message}`);
|
||||
throw new Error(`[TMDB] Failed to fetch movie studio: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,7 +870,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch TV network: ${e.message}`);
|
||||
throw new Error(`[TMDB] Failed to fetch TV network: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -810,7 +921,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return movieGenres;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch movie genres: ${e.message}`);
|
||||
throw new Error(`[TMDB] Failed to fetch movie genres: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -861,7 +972,153 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return tvGenres;
|
||||
} catch (e) {
|
||||
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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,18 @@ export interface TmdbTvResult extends TmdbMediaResult {
|
||||
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 {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -45,7 +57,12 @@ interface TmdbPaginatedResponse {
|
||||
}
|
||||
|
||||
export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse {
|
||||
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[];
|
||||
results: (
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
| TmdbPersonResult
|
||||
| TmdbCollectionResult
|
||||
)[];
|
||||
}
|
||||
|
||||
export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse {
|
||||
@@ -171,6 +188,9 @@ export interface TmdbMovieDetails {
|
||||
id: number;
|
||||
results?: { [iso_3166_1: string]: TmdbWatchProviders };
|
||||
};
|
||||
keywords: {
|
||||
keywords: TmdbKeyword[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface TmdbVideo {
|
||||
@@ -191,7 +211,7 @@ export interface TmdbVideo {
|
||||
|
||||
export interface TmdbTvEpisodeResult {
|
||||
id: number;
|
||||
air_date: string;
|
||||
air_date: string | null;
|
||||
episode_number: number;
|
||||
name: string;
|
||||
overview: string;
|
||||
@@ -372,7 +392,8 @@ export interface TmdbPersonCombinedCredits {
|
||||
crew: TmdbPersonCreditCrew[];
|
||||
}
|
||||
|
||||
export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult {
|
||||
export interface TmdbSeasonWithEpisodes
|
||||
extends Omit<TmdbTvSeasonResult, 'episode_count'> {
|
||||
episodes: TmdbTvEpisodeResult[];
|
||||
external_ids: TmdbExternalIds;
|
||||
}
|
||||
@@ -427,3 +448,24 @@ export interface TmdbWatchProviderDetails {
|
||||
provider_id: number;
|
||||
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,
|
||||
},
|
||||
];
|
||||
@@ -2,6 +2,7 @@ export enum MediaRequestStatus {
|
||||
PENDING = 1,
|
||||
APPROVED,
|
||||
DECLINED,
|
||||
FAILED,
|
||||
}
|
||||
|
||||
export enum MediaType {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
const devConfig = {
|
||||
import 'reflect-metadata';
|
||||
import type { DataSourceOptions, EntityTarget, Repository } from 'typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
const devConfig: DataSourceOptions = {
|
||||
type: 'sqlite',
|
||||
database: process.env.CONFIG_DIRECTORY
|
||||
? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3`
|
||||
@@ -10,31 +14,30 @@ const devConfig = {
|
||||
entities: ['server/entity/**/*.ts'],
|
||||
migrations: ['server/migration/**/*.ts'],
|
||||
subscribers: ['server/subscriber/**/*.ts'],
|
||||
cli: {
|
||||
entitiesDir: 'server/entity',
|
||||
migrationsDir: 'server/migration',
|
||||
},
|
||||
};
|
||||
|
||||
const prodConfig = {
|
||||
const prodConfig: DataSourceOptions = {
|
||||
type: 'sqlite',
|
||||
database: process.env.CONFIG_DIRECTORY
|
||||
? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3`
|
||||
: 'config/db/db.sqlite3',
|
||||
synchronize: false,
|
||||
migrationsRun: false,
|
||||
logging: false,
|
||||
enableWAL: true,
|
||||
entities: ['dist/entity/**/*.js'],
|
||||
migrations: ['dist/migration/**/*.js'],
|
||||
migrationsRun: false,
|
||||
subscribers: ['dist/subscriber/**/*.js'],
|
||||
cli: {
|
||||
entitiesDir: 'dist/entity',
|
||||
migrationsDir: 'dist/migration',
|
||||
},
|
||||
};
|
||||
|
||||
const finalConfig =
|
||||
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig;
|
||||
const dataSource = new DataSource(
|
||||
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig
|
||||
);
|
||||
|
||||
module.exports = finalConfig;
|
||||
export const getRepository = <Entity extends object>(
|
||||
target: EntityTarget<Entity>
|
||||
): Repository<Entity> => {
|
||||
return dataSource.getRepository(target);
|
||||
};
|
||||
|
||||
export default dataSource;
|
||||
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 {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
@@ -7,7 +9,6 @@ import {
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { IssueStatus, IssueType } from '../constants/issue';
|
||||
import IssueComment from './IssueComment';
|
||||
import Media from './Media';
|
||||
import { User } from './User';
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
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 {
|
||||
AfterLoad,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
getRepository,
|
||||
In,
|
||||
Index,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import RadarrAPI from '../api/servarr/radarr';
|
||||
import SonarrAPI from '../api/servarr/sonarr';
|
||||
import { MediaStatus, MediaType } from '../constants/media';
|
||||
import downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import Issue from './Issue';
|
||||
import { MediaRequest } from './MediaRequest';
|
||||
import Season from './Season';
|
||||
@@ -36,7 +37,7 @@ class Media {
|
||||
}
|
||||
|
||||
const media = await mediaRepository.find({
|
||||
tmdbId: In(finalIds),
|
||||
where: { tmdbId: In(finalIds) },
|
||||
});
|
||||
|
||||
return media;
|
||||
@@ -55,10 +56,10 @@ class Media {
|
||||
try {
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { tmdbId: id, mediaType },
|
||||
relations: ['requests', 'issues'],
|
||||
relations: { requests: true, issues: true },
|
||||
});
|
||||
|
||||
return media;
|
||||
return media ?? undefined;
|
||||
} catch (e) {
|
||||
logger.error(e.message);
|
||||
return undefined;
|
||||
@@ -113,29 +114,29 @@ class Media {
|
||||
@Column({ type: 'datetime', nullable: true })
|
||||
public mediaAddedAt: Date;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public serviceId?: number;
|
||||
@Column({ nullable: true, type: 'int' })
|
||||
public serviceId?: number | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public serviceId4k?: number;
|
||||
@Column({ nullable: true, type: 'int' })
|
||||
public serviceId4k?: number | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public externalServiceId?: number;
|
||||
@Column({ nullable: true, type: 'int' })
|
||||
public externalServiceId?: number | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public externalServiceId4k?: number;
|
||||
@Column({ nullable: true, type: 'int' })
|
||||
public externalServiceId4k?: number | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public externalServiceSlug?: string;
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
public externalServiceSlug?: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public externalServiceSlug4k?: string;
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
public externalServiceSlug4k?: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public ratingKey?: string;
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
public ratingKey?: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public ratingKey4k?: string;
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
public ratingKey4k?: string | null;
|
||||
|
||||
public serviceUrl?: string;
|
||||
public serviceUrl4k?: string;
|
||||
@@ -145,6 +146,9 @@ class Media {
|
||||
public plexUrl?: string;
|
||||
public plexUrl4k?: string;
|
||||
|
||||
public iOSPlexUrl?: string;
|
||||
public iOSPlexUrl4k?: string;
|
||||
|
||||
public tautulliUrl?: string;
|
||||
public tautulliUrl4k?: string;
|
||||
|
||||
@@ -164,6 +168,8 @@ class Media {
|
||||
this.ratingKey
|
||||
}`;
|
||||
|
||||
this.iOSPlexUrl = `plex://preplay/?metadataKey=%2Flibrary%2Fmetadata%2F${this.ratingKey}&server=${machineId}`;
|
||||
|
||||
if (tautulliUrl) {
|
||||
this.tautulliUrl = `${tautulliUrl}/info?rating_key=${this.ratingKey}`;
|
||||
}
|
||||
@@ -176,6 +182,8 @@ class Media {
|
||||
this.ratingKey4k
|
||||
}`;
|
||||
|
||||
this.iOSPlexUrl4k = `plex://preplay/?metadataKey=%2Flibrary%2Fmetadata%2F${this.ratingKey4k}&server=${machineId}`;
|
||||
|
||||
if (tautulliUrl) {
|
||||
this.tautulliUrl4k = `${tautulliUrl}/info?rating_key=${this.ratingKey4k}`;
|
||||
}
|
||||
@@ -252,7 +260,9 @@ class Media {
|
||||
if (this.mediaType === MediaType.MOVIE) {
|
||||
if (
|
||||
this.externalServiceId !== undefined &&
|
||||
this.serviceId !== undefined
|
||||
this.externalServiceId !== null &&
|
||||
this.serviceId !== undefined &&
|
||||
this.serviceId !== null
|
||||
) {
|
||||
this.downloadStatus = downloadTracker.getMovieProgress(
|
||||
this.serviceId,
|
||||
@@ -262,7 +272,9 @@ class Media {
|
||||
|
||||
if (
|
||||
this.externalServiceId4k !== undefined &&
|
||||
this.serviceId4k !== undefined
|
||||
this.externalServiceId4k !== null &&
|
||||
this.serviceId4k !== undefined &&
|
||||
this.serviceId4k !== null
|
||||
) {
|
||||
this.downloadStatus4k = downloadTracker.getMovieProgress(
|
||||
this.serviceId4k,
|
||||
@@ -274,7 +286,9 @@ class Media {
|
||||
if (this.mediaType === MediaType.TV) {
|
||||
if (
|
||||
this.externalServiceId !== undefined &&
|
||||
this.serviceId !== undefined
|
||||
this.externalServiceId !== null &&
|
||||
this.serviceId !== undefined &&
|
||||
this.serviceId !== null
|
||||
) {
|
||||
this.downloadStatus = downloadTracker.getSeriesProgress(
|
||||
this.serviceId,
|
||||
@@ -284,7 +298,9 @@ class Media {
|
||||
|
||||
if (
|
||||
this.externalServiceId4k !== undefined &&
|
||||
this.serviceId4k !== undefined
|
||||
this.externalServiceId4k !== null &&
|
||||
this.serviceId4k !== undefined &&
|
||||
this.serviceId4k !== null
|
||||
) {
|
||||
this.downloadStatus4k = downloadTracker.getSeriesProgress(
|
||||
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 {
|
||||
AfterInsert,
|
||||
@@ -6,30 +26,347 @@ import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
getRepository,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
RelationCount,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import RadarrAPI, { RadarrMovieOptions } from '../api/servarr/radarr';
|
||||
import SonarrAPI, {
|
||||
AddSeriesOptions,
|
||||
SonarrSeries,
|
||||
} from '../api/servarr/sonarr';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants';
|
||||
import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import Media from './Media';
|
||||
import SeasonRequest from './SeasonRequest';
|
||||
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()
|
||||
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)
|
||||
.filter((sn) => sn > 0)
|
||||
: (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()
|
||||
public id: number;
|
||||
|
||||
@@ -120,6 +457,9 @@ export class MediaRequest {
|
||||
})
|
||||
public tags?: number[];
|
||||
|
||||
@Column({ default: false })
|
||||
public isAutoRequest: boolean;
|
||||
|
||||
constructor(init?: Partial<MediaRequest>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
@@ -147,6 +487,10 @@ export class MediaRequest {
|
||||
}
|
||||
|
||||
this.sendNotification(media, Notification.MEDIA_PENDING);
|
||||
|
||||
if (this.isAutoRequest) {
|
||||
this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +535,14 @@ export class MediaRequest {
|
||||
: Notification.MEDIA_APPROVED
|
||||
: Notification.MEDIA_DECLINED
|
||||
);
|
||||
|
||||
if (
|
||||
this.status === MediaRequestStatus.APPROVED &&
|
||||
autoApproved &&
|
||||
this.isAutoRequest
|
||||
) {
|
||||
this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +559,7 @@ export class MediaRequest {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: this.media.id },
|
||||
relations: ['requests'],
|
||||
relations: { requests: true },
|
||||
});
|
||||
if (!media) {
|
||||
logger.error('Media data not found', {
|
||||
@@ -272,7 +624,7 @@ export class MediaRequest {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const fullMedia = await mediaRepository.findOneOrFail({
|
||||
where: { id: this.media.id },
|
||||
relations: ['requests'],
|
||||
relations: { requests: true },
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -352,7 +704,7 @@ export class MediaRequest {
|
||||
|
||||
let rootFolder = radarrSettings.activeDirectory;
|
||||
let qualityProfile = radarrSettings.activeProfileId;
|
||||
let tags = radarrSettings.tags;
|
||||
let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
|
||||
|
||||
if (
|
||||
this.rootFolder &&
|
||||
@@ -412,10 +764,51 @@ export class MediaRequest {
|
||||
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 (
|
||||
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 = {
|
||||
@@ -452,10 +845,13 @@ export class MediaRequest {
|
||||
await mediaRepository.save(media);
|
||||
})
|
||||
.catch(async () => {
|
||||
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
this.status = MediaRequestStatus.FAILED;
|
||||
requestRepository.save(this);
|
||||
|
||||
logger.warn(
|
||||
'Something went wrong sending movie request to Radarr, marking status as UNKNOWN',
|
||||
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
@@ -543,7 +939,7 @@ export class MediaRequest {
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: this.media.id },
|
||||
relations: ['requests'],
|
||||
relations: { requests: true },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
@@ -553,7 +949,16 @@ export class MediaRequest {
|
||||
if (
|
||||
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();
|
||||
@@ -597,7 +1002,11 @@ export class MediaRequest {
|
||||
let tags =
|
||||
seriesType === 'anime'
|
||||
? sonarrSettings.animeTags
|
||||
: sonarrSettings.tags;
|
||||
? [...sonarrSettings.animeTags]
|
||||
: []
|
||||
: sonarrSettings.tags
|
||||
? [...sonarrSettings.tags]
|
||||
: [];
|
||||
|
||||
if (
|
||||
this.rootFolder &&
|
||||
@@ -649,6 +1058,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 = {
|
||||
profileId: qualityProfile,
|
||||
languageProfileId: languageProfile,
|
||||
@@ -670,7 +1111,7 @@ export class MediaRequest {
|
||||
// We grab media again here to make sure we have the latest version of it
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: this.media.id },
|
||||
relations: ['requests'],
|
||||
relations: { requests: true },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
@@ -685,10 +1126,13 @@ export class MediaRequest {
|
||||
await mediaRepository.save(media);
|
||||
})
|
||||
.catch(async () => {
|
||||
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
this.status = MediaRequestStatus.FAILED;
|
||||
requestRepository.save(this);
|
||||
|
||||
logger.warn(
|
||||
'Something went wrong sending series request to Sonarr, marking status as UNKNOWN',
|
||||
'Something went wrong sending series request to Sonarr, marking status as FAILED',
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
@@ -723,6 +1167,7 @@ export class MediaRequest {
|
||||
const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series';
|
||||
let event: string | undefined;
|
||||
let notifyAdmin = true;
|
||||
let notifySystem = true;
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_APPROVED:
|
||||
@@ -736,6 +1181,13 @@ export class MediaRequest {
|
||||
case Notification.MEDIA_PENDING:
|
||||
event = `New ${this.is4k ? '4K ' : ''}${mediaType} Request`;
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_REQUESTED:
|
||||
event = `${
|
||||
this.is4k ? '4K ' : ''
|
||||
}${mediaType} Request Automatically Submitted`;
|
||||
notifyAdmin = false;
|
||||
notifySystem = false;
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
event = `${
|
||||
this.is4k ? '4K ' : ''
|
||||
@@ -752,6 +1204,7 @@ export class MediaRequest {
|
||||
media,
|
||||
request: this,
|
||||
notifyAdmin,
|
||||
notifySystem,
|
||||
notifyUser: notifyAdmin ? undefined : this.requestedBy,
|
||||
event,
|
||||
subject: `${movie.title}${
|
||||
@@ -770,6 +1223,7 @@ export class MediaRequest {
|
||||
media,
|
||||
request: this,
|
||||
notifyAdmin,
|
||||
notifySystem,
|
||||
notifyUser: notifyAdmin ? undefined : this.requestedBy,
|
||||
event,
|
||||
subject: `${tv.name}${
|
||||
@@ -801,3 +1255,5 @@ export class MediaRequest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default MediaRequest;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { MediaStatus } from '../constants/media';
|
||||
import Media from './Media';
|
||||
|
||||
@Entity()
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { MediaRequestStatus } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
AfterRemove,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { MediaRequestStatus } from '../constants/media';
|
||||
import { MediaRequest } from './MediaRequest';
|
||||
|
||||
@Entity()
|
||||
@@ -34,6 +36,18 @@ class SeasonRequest {
|
||||
constructor(init?: Partial<SeasonRequest>) {
|
||||
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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ISession } from 'connect-typeorm';
|
||||
import { Index, Column, PrimaryColumn, Entity } from 'typeorm';
|
||||
import type { ISession } from 'connect-typeorm';
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
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 { randomUUID } from 'crypto';
|
||||
import path from 'path';
|
||||
@@ -7,8 +17,6 @@ import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
getRepository,
|
||||
MoreThan,
|
||||
Not,
|
||||
OneToMany,
|
||||
OneToOne,
|
||||
@@ -16,17 +24,6 @@ import {
|
||||
RelationCount,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { MediaRequestStatus, MediaType } from '../constants/media';
|
||||
import { UserType } from '../constants/user';
|
||||
import { QuotaResponse } from '../interfaces/api/userInterfaces';
|
||||
import PreparedEmail from '../lib/email';
|
||||
import {
|
||||
hasPermission,
|
||||
Permission,
|
||||
PermissionCheckOptions,
|
||||
} from '../lib/permissions';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import Issue from './Issue';
|
||||
import { MediaRequest } from './MediaRequest';
|
||||
import SeasonRequest from './SeasonRequest';
|
||||
@@ -42,7 +39,7 @@ export class User {
|
||||
return users.map((u) => u.filter(showFiltered));
|
||||
}
|
||||
|
||||
static readonly filteredFields: string[] = ['email'];
|
||||
static readonly filteredFields: string[] = ['email', 'plexId'];
|
||||
|
||||
public displayName: string;
|
||||
|
||||
@@ -76,7 +73,7 @@ export class User {
|
||||
@Column({ type: 'integer', default: UserType.PLEX })
|
||||
public userType: UserType;
|
||||
|
||||
@Column({ nullable: true, select: false })
|
||||
@Column({ nullable: true, select: true })
|
||||
public plexId?: number;
|
||||
|
||||
@Column({ nullable: true, select: false })
|
||||
@@ -255,13 +252,14 @@ export class User {
|
||||
if (movieQuotaDays) {
|
||||
movieDate.setDate(movieDate.getDate() - movieQuotaDays);
|
||||
}
|
||||
const movieQuotaStartDate = movieDate.toJSON();
|
||||
|
||||
const movieQuotaUsed = movieQuotaLimit
|
||||
? await requestRepository.count({
|
||||
where: {
|
||||
requestedBy: this,
|
||||
createdAt: MoreThan(movieQuotaStartDate),
|
||||
requestedBy: {
|
||||
id: this.id,
|
||||
},
|
||||
createdAt: AfterDate(movieDate),
|
||||
type: MediaType.MOVIE,
|
||||
status: Not(MediaRequestStatus.DECLINED),
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
Column,
|
||||
Entity,
|
||||
@@ -5,9 +8,6 @@ import {
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { NotificationAgentTypes } from '../interfaces/api/userSettingsInterfaces';
|
||||
import { hasNotificationType, Notification } from '../lib/notifications';
|
||||
import { NotificationAgentKey } from '../lib/settings';
|
||||
import { User } from './User';
|
||||
|
||||
export const ALL_NOTIFICATIONS = Object.values(Notification)
|
||||
@@ -57,6 +57,12 @@ export class UserSettings {
|
||||
@Column({ nullable: true })
|
||||
public telegramSendSilently?: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public watchlistSyncMovies?: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public watchlistSyncTv?: boolean;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
|
||||
@@ -1,34 +1,40 @@
|
||||
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 { TypeormStore } from 'connect-typeorm/out';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import csurf from 'csurf';
|
||||
import express, { NextFunction, Request, Response } from 'express';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import express from 'express';
|
||||
import * as OpenApiValidator from 'express-openapi-validator';
|
||||
import session, { Store } from 'express-session';
|
||||
import type { Store } from 'express-session';
|
||||
import session from 'express-session';
|
||||
import next from 'next';
|
||||
import path from 'path';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import { createConnection, getRepository } from 'typeorm';
|
||||
import YAML from 'yamljs';
|
||||
import PlexAPI from './api/plexapi';
|
||||
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';
|
||||
|
||||
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
|
||||
|
||||
@@ -40,7 +46,7 @@ const handle = app.getRequestHandler();
|
||||
app
|
||||
.prepare()
|
||||
.then(async () => {
|
||||
const dbConnection = await createConnection();
|
||||
const dbConnection = await dataSource.initialize();
|
||||
|
||||
// Run migrations in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
@@ -51,6 +57,7 @@ app
|
||||
|
||||
// Load Settings
|
||||
const settings = getSettings().load();
|
||||
restartFlag.initializeSettings(settings.main);
|
||||
|
||||
// Migrate library types
|
||||
if (
|
||||
@@ -59,8 +66,8 @@ app
|
||||
) {
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOne({
|
||||
select: ['id', 'plexToken'],
|
||||
order: { id: 'ASC' },
|
||||
select: { id: true, plexToken: true },
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
if (admin) {
|
||||
@@ -90,6 +97,9 @@ app
|
||||
// Start Jobs
|
||||
startJobs();
|
||||
|
||||
// Bootstrap Discovery Sliders
|
||||
await DiscoverSlider.bootstrapSliders();
|
||||
|
||||
const server = express();
|
||||
if (settings.main.trustProxy) {
|
||||
server.enable('trust proxy');
|
||||
@@ -172,6 +182,10 @@ app
|
||||
next();
|
||||
});
|
||||
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.use(
|
||||
(
|
||||
|
||||
@@ -3,3 +3,17 @@ export interface GenreSliderItem {
|
||||
name: 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,5 +1,5 @@
|
||||
import Issue from '../../entity/Issue';
|
||||
import { PaginatedResponse } from './common';
|
||||
import type Issue from '@server/entity/Issue';
|
||||
import type { PaginatedResponse } from './common';
|
||||
|
||||
export interface IssueResultsResponse extends PaginatedResponse {
|
||||
results: Issue[];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type Media from '../../entity/Media';
|
||||
import { User } from '../../entity/User';
|
||||
import { PaginatedResponse } from './common';
|
||||
import type Media from '@server/entity/Media';
|
||||
import type { User } from '@server/entity/User';
|
||||
import type { PaginatedResponse } from './common';
|
||||
|
||||
export interface MediaResultsResponse extends PaginatedResponse {
|
||||
results: Media[];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PersonCreditCast, PersonCreditCrew } from '../../models/Person';
|
||||
import type { PersonCreditCast, PersonCreditCrew } from '@server/models/Person';
|
||||
|
||||
export interface PersonCombinedCreditsResponse {
|
||||
id: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PlexSettings } from '../../lib/settings';
|
||||
import type { PlexSettings } from '@server/lib/settings';
|
||||
|
||||
export interface PlexStatus {
|
||||
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 { MediaRequest } from '../../entity/MediaRequest';
|
||||
|
||||
export interface RequestResultsResponse extends PaginatedResponse {
|
||||
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 { QualityProfile, RootFolder, Tag } from '../../api/servarr/base';
|
||||
import { LanguageProfile } from '../../api/servarr/sonarr';
|
||||
import type { QualityProfile, RootFolder, Tag } from '@server/api/servarr/base';
|
||||
import type { LanguageProfile } from '@server/api/servarr/sonarr';
|
||||
|
||||
export interface ServiceCommonServer {
|
||||
id: number;
|
||||
|
||||
@@ -51,9 +51,15 @@ export interface CacheItem {
|
||||
};
|
||||
}
|
||||
|
||||
export interface CacheResponse {
|
||||
apiCaches: CacheItem[];
|
||||
imageCache: Record<'tmdb', { size: number; imageCount: number }>;
|
||||
}
|
||||
|
||||
export interface StatusResponse {
|
||||
version: string;
|
||||
commitTag: string;
|
||||
updateAvailable: boolean;
|
||||
commitsBehind: number;
|
||||
restartRequired: boolean;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Media from '../../entity/Media';
|
||||
import { MediaRequest } from '../../entity/MediaRequest';
|
||||
import type { User } from '../../entity/User';
|
||||
import { PaginatedResponse } from './common';
|
||||
import type Media from '@server/entity/Media';
|
||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type { User } from '@server/entity/User';
|
||||
import type { PaginatedResponse } from './common';
|
||||
|
||||
export interface UserResultsResponse extends PaginatedResponse {
|
||||
results: User[];
|
||||
@@ -23,6 +23,7 @@ export interface QuotaResponse {
|
||||
movie: QuotaStatus;
|
||||
tv: QuotaStatus;
|
||||
}
|
||||
|
||||
export interface UserWatchDataResponse {
|
||||
recentlyWatched: Media[];
|
||||
playCount: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NotificationAgentKey } from '../../lib/settings';
|
||||
import type { NotificationAgentKey } from '@server/lib/settings';
|
||||
|
||||
export interface UserSettingsGeneralResponse {
|
||||
username?: string;
|
||||
@@ -14,6 +14,8 @@ export interface UserSettingsGeneralResponse {
|
||||
globalMovieQuotaLimit?: number;
|
||||
globalTvQuotaLimit?: number;
|
||||
globalTvQuotaDays?: number;
|
||||
watchlistSyncMovies?: boolean;
|
||||
watchlistSyncTv?: boolean;
|
||||
}
|
||||
|
||||
export type NotificationAgentTypes = Record<NotificationAgentKey, number>;
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import availabilitySync from '@server/lib/availabilitySync';
|
||||
import downloadTracker from '@server/lib/downloadtracker';
|
||||
import ImageProxy from '@server/lib/imageproxy';
|
||||
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 random from 'lodash/random';
|
||||
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 { getSettings, JobId } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
|
||||
interface ScheduledJob {
|
||||
id: JobId;
|
||||
job: schedule.Job;
|
||||
name: string;
|
||||
type: 'process' | 'command';
|
||||
interval: 'short' | 'long' | 'fixed';
|
||||
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
|
||||
cronSchedule: string;
|
||||
running?: () => boolean;
|
||||
cancelFn?: () => void;
|
||||
}
|
||||
@@ -26,7 +32,8 @@ export const startJobs = (): void => {
|
||||
id: 'plex-recently-added-scan',
|
||||
name: 'Plex Recently Added Scan',
|
||||
type: 'process',
|
||||
interval: 'short',
|
||||
interval: 'minutes',
|
||||
cronSchedule: jobs['plex-recently-added-scan'].schedule,
|
||||
job: schedule.scheduleJob(jobs['plex-recently-added-scan'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Plex Recently Added Scan', {
|
||||
label: 'Jobs',
|
||||
@@ -42,7 +49,8 @@ export const startJobs = (): void => {
|
||||
id: 'plex-full-scan',
|
||||
name: 'Plex Full Library Scan',
|
||||
type: 'process',
|
||||
interval: 'long',
|
||||
interval: 'hours',
|
||||
cronSchedule: jobs['plex-full-scan'].schedule,
|
||||
job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Plex Full Library Scan', {
|
||||
label: 'Jobs',
|
||||
@@ -53,12 +61,38 @@ export const startJobs = (): void => {
|
||||
cancelFn: () => plexFullScanner.cancel(),
|
||||
});
|
||||
|
||||
// Watchlist Sync
|
||||
const watchlistSyncJob: ScheduledJob = {
|
||||
id: 'plex-watchlist-sync',
|
||||
name: 'Plex Watchlist Sync',
|
||||
type: 'process',
|
||||
interval: 'fixed',
|
||||
cronSchedule: jobs['plex-watchlist-sync'].schedule,
|
||||
job: schedule.scheduleJob(new Date(Date.now() + 1000 * 60 * 20), () => {
|
||||
logger.info('Starting scheduled job: Plex Watchlist Sync', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
watchlistSync.syncWatchlist();
|
||||
}),
|
||||
};
|
||||
|
||||
// To help alleviate load on Plex's servers, we will add some fuzziness to the next schedule
|
||||
// after each run
|
||||
watchlistSyncJob.job.on('run', () => {
|
||||
watchlistSyncJob.job.schedule(
|
||||
new Date(Math.floor(Date.now() + 1000 * 60 * random(14, 24, true)))
|
||||
);
|
||||
});
|
||||
|
||||
scheduledJobs.push(watchlistSyncJob);
|
||||
|
||||
// Run full radarr scan every 24 hours
|
||||
scheduledJobs.push({
|
||||
id: 'radarr-scan',
|
||||
name: 'Radarr Scan',
|
||||
type: 'process',
|
||||
interval: 'long',
|
||||
interval: 'hours',
|
||||
cronSchedule: jobs['radarr-scan'].schedule,
|
||||
job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
|
||||
radarrScanner.run();
|
||||
@@ -72,7 +106,8 @@ export const startJobs = (): void => {
|
||||
id: 'sonarr-scan',
|
||||
name: 'Sonarr Scan',
|
||||
type: 'process',
|
||||
interval: 'long',
|
||||
interval: 'hours',
|
||||
cronSchedule: jobs['sonarr-scan'].schedule,
|
||||
job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
|
||||
sonarrScanner.run();
|
||||
@@ -81,12 +116,30 @@ export const startJobs = (): void => {
|
||||
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
|
||||
scheduledJobs.push({
|
||||
id: 'download-sync',
|
||||
name: 'Download Sync',
|
||||
type: 'command',
|
||||
interval: 'fixed',
|
||||
interval: 'seconds',
|
||||
cronSchedule: jobs['download-sync'].schedule,
|
||||
job: schedule.scheduleJob(jobs['download-sync'].schedule, () => {
|
||||
logger.debug('Starting scheduled job: Download Sync', {
|
||||
label: 'Jobs',
|
||||
@@ -100,7 +153,8 @@ export const startJobs = (): void => {
|
||||
id: 'download-sync-reset',
|
||||
name: 'Download Sync Reset',
|
||||
type: 'command',
|
||||
interval: 'long',
|
||||
interval: 'hours',
|
||||
cronSchedule: jobs['download-sync-reset'].schedule,
|
||||
job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Download Sync Reset', {
|
||||
label: 'Jobs',
|
||||
@@ -109,5 +163,21 @@ 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');
|
||||
}),
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -6,7 +6,8 @@ export type AvailableCacheIds =
|
||||
| 'sonarr'
|
||||
| 'rt'
|
||||
| 'github'
|
||||
| 'plexguid';
|
||||
| 'plexguid'
|
||||
| 'plextv';
|
||||
|
||||
const DEFAULT_TTL = 300;
|
||||
const DEFAULT_CHECK_PERIOD = 120;
|
||||
@@ -58,6 +59,10 @@ class CacheManager {
|
||||
stdTtl: 86400 * 7, // 1 week cache
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
plextv: new Cache('plextv', 'Plex TV', {
|
||||
stdTtl: 86400 * 7, // 1 week cache
|
||||
checkPeriod: 60,
|
||||
}),
|
||||
};
|
||||
|
||||
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 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 {
|
||||
mediaType: MediaType;
|
||||
externalId: number;
|
||||
@@ -14,6 +20,7 @@ export interface DownloadingItem {
|
||||
timeLeft: string;
|
||||
estimatedCompletionTime: Date;
|
||||
title: string;
|
||||
episode?: EpisodeNumberResult;
|
||||
}
|
||||
|
||||
class DownloadTracker {
|
||||
@@ -164,6 +171,7 @@ class DownloadTracker {
|
||||
status: item.status,
|
||||
timeLeft: item.timeleft,
|
||||
title: item.title,
|
||||
episode: item.episode,
|
||||
}));
|
||||
|
||||
if (queueItems.length > 0) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { NotificationAgentEmail } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import Email from 'email-templates';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { URL } from 'url';
|
||||
import { getSettings, NotificationAgentEmail } from '../settings';
|
||||
import { openpgpEncrypt } from './openpgpEncrypt';
|
||||
|
||||
class PreparedEmail extends Email {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import logger from '@server/logger';
|
||||
import { randomBytes } from 'crypto';
|
||||
import * as openpgp from 'openpgp';
|
||||
import { Transform, TransformCallback } from 'stream';
|
||||
import logger from '../../logger';
|
||||
import type { TransformCallback } from 'stream';
|
||||
import { Transform } from 'stream';
|
||||
|
||||
interface EncryptorOptions {
|
||||
signingKey?: string;
|
||||
@@ -26,7 +27,7 @@ class PGPEncryptor extends Transform {
|
||||
|
||||
// just save the whole message
|
||||
_transform = (
|
||||
chunk: any,
|
||||
chunk: Uint8Array,
|
||||
_encoding: BufferEncoding,
|
||||
callback: TransformCallback
|
||||
): void => {
|
||||
@@ -184,6 +185,9 @@ class PGPEncryptor extends Transform {
|
||||
}
|
||||
|
||||
export const openpgpEncrypt = (options: EncryptorOptions) => {
|
||||
// Disabling this line because I don't want to fix it but I am tired
|
||||
// of seeing the lint warning
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return function (mail: any, callback: () => unknown): void {
|
||||
if (!options.encryptionKeys.length) {
|
||||
setImmediate(callback);
|
||||
|
||||
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 { Notification } from '..';
|
||||
import type Issue from '../../../entity/Issue';
|
||||
import IssueComment from '../../../entity/IssueComment';
|
||||
import Media from '../../../entity/Media';
|
||||
import { MediaRequest } from '../../../entity/MediaRequest';
|
||||
import { User } from '../../../entity/User';
|
||||
import { NotificationAgentConfig } from '../../settings';
|
||||
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 '..';
|
||||
|
||||
export interface NotificationPayload {
|
||||
event?: string;
|
||||
subject: string;
|
||||
notifySystem: boolean;
|
||||
notifyAdmin: boolean;
|
||||
notifyUser?: User;
|
||||
media?: Media;
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
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 { getRepository } from 'typeorm';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||
import { User } from '../../../entity/User';
|
||||
import logger from '../../../logger';
|
||||
import {
|
||||
getSettings,
|
||||
NotificationAgentDiscord,
|
||||
NotificationAgentKey,
|
||||
} from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
enum EmbedColors {
|
||||
DEFAULT = 0,
|
||||
@@ -245,7 +243,10 @@ class DiscordAgent
|
||||
): Promise<boolean> {
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (!hasNotificationType(type, settings.types ?? 0)) {
|
||||
if (
|
||||
!payload.notifySystem ||
|
||||
!hasNotificationType(type, settings.types ?? 0)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { EmailOptions } from 'email-templates';
|
||||
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 path from 'path';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { Notification, shouldSendAdminNotification } from '..';
|
||||
import { IssueType, IssueTypeName } from '../../../constants/issue';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
import { User } from '../../../entity/User';
|
||||
import logger from '../../../logger';
|
||||
import PreparedEmail from '../../email';
|
||||
import {
|
||||
getSettings,
|
||||
NotificationAgentEmail,
|
||||
NotificationAgentKey,
|
||||
} from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
class EmailAgent
|
||||
extends BaseAgent<NotificationAgentEmail>
|
||||
@@ -83,6 +81,11 @@ class EmailAgent
|
||||
is4k ? 'in 4K ' : ''
|
||||
}is pending approval:`;
|
||||
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:
|
||||
body = `Your request for the following ${mediaType} ${
|
||||
is4k ? 'in 4K ' : ''
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
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 { hasNotificationType, Notification } from '..';
|
||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentGotify } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
interface GotifyPayload {
|
||||
title: string;
|
||||
message: string;
|
||||
priority: number;
|
||||
extras: any;
|
||||
extras: Record<string, unknown>;
|
||||
}
|
||||
|
||||
class GotifyAgent
|
||||
@@ -115,7 +117,10 @@ class GotifyAgent
|
||||
): Promise<boolean> {
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (!hasNotificationType(type, settings.types ?? 0)) {
|
||||
if (
|
||||
!payload.notifySystem ||
|
||||
!hasNotificationType(type, settings.types ?? 0)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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 { hasNotificationType, Notification } from '..';
|
||||
import { IssueStatus, IssueType } from '../../../constants/issue';
|
||||
import { MediaStatus } from '../../../constants/media';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentLunaSea } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
class LunaSeaAgent
|
||||
extends BaseAgent<NotificationAgentLunaSea>
|
||||
@@ -85,7 +87,10 @@ class LunaSeaAgent
|
||||
): Promise<boolean> {
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (!hasNotificationType(type, settings.types ?? 0)) {
|
||||
if (
|
||||
!payload.notifySystem ||
|
||||
!hasNotificationType(type, settings.types ?? 0)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
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 { getRepository } from 'typeorm';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||
import { User } from '../../../entity/User';
|
||||
import logger from '../../../logger';
|
||||
import {
|
||||
getSettings,
|
||||
NotificationAgentKey,
|
||||
NotificationAgentPushbullet,
|
||||
} from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
interface PushbulletPayload {
|
||||
type: string;
|
||||
@@ -54,6 +53,12 @@ class PushbulletAgent
|
||||
|
||||
let status = '';
|
||||
switch (type) {
|
||||
case Notification.MEDIA_AUTO_REQUESTED:
|
||||
status =
|
||||
payload.media?.status === MediaStatus.PENDING
|
||||
? 'Pending Approval'
|
||||
: 'Processing';
|
||||
break;
|
||||
case Notification.MEDIA_PENDING:
|
||||
status = 'Pending Approval';
|
||||
break;
|
||||
@@ -106,6 +111,7 @@ class PushbulletAgent
|
||||
|
||||
// Send system notification
|
||||
if (
|
||||
payload.notifySystem &&
|
||||
hasNotificationType(type, settings.types ?? 0) &&
|
||||
settings.enabled &&
|
||||
settings.options.accessToken
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
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 { getRepository } from 'typeorm';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||
import { User } from '../../../entity/User';
|
||||
import logger from '../../../logger';
|
||||
import {
|
||||
getSettings,
|
||||
NotificationAgentKey,
|
||||
NotificationAgentPushover,
|
||||
} from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
interface PushoverPayload {
|
||||
token: string;
|
||||
@@ -63,6 +62,12 @@ class PushoverAgent
|
||||
|
||||
let status = '';
|
||||
switch (type) {
|
||||
case Notification.MEDIA_AUTO_REQUESTED:
|
||||
status =
|
||||
payload.media?.status === MediaStatus.PENDING
|
||||
? 'Pending Approval'
|
||||
: 'Processing';
|
||||
break;
|
||||
case Notification.MEDIA_PENDING:
|
||||
status = 'Pending Approval';
|
||||
break;
|
||||
@@ -137,6 +142,7 @@ class PushoverAgent
|
||||
|
||||
// Send system notification
|
||||
if (
|
||||
payload.notifySystem &&
|
||||
hasNotificationType(type, settings.types ?? 0) &&
|
||||
settings.enabled &&
|
||||
settings.options.accessToken &&
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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 { hasNotificationType, Notification } from '..';
|
||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentSlack } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
|
||||
interface EmbedField {
|
||||
type: 'plain_text' | 'mrkdwn';
|
||||
@@ -223,7 +225,10 @@ class SlackAgent
|
||||
): Promise<boolean> {
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (!hasNotificationType(type, settings.types ?? 0)) {
|
||||
if (
|
||||
!payload.notifySystem ||
|
||||
!hasNotificationType(type, settings.types ?? 0)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user