Compare commits
319 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b24c23e549 | ||
|
|
e602e9f758 | ||
|
|
ebc285c758 | ||
|
|
afd46ebee8 | ||
|
|
be47d1e56c | ||
|
|
2f2e00237d | ||
|
|
3d6b343413 | ||
|
|
9e5adeb610 | ||
|
|
ae29e5c5a2 | ||
|
|
dc8576768f | ||
|
|
422ef2cb76 | ||
|
|
001dcd328c | ||
|
|
0bd0912613 | ||
|
|
3148d31214 | ||
|
|
36584eee85 | ||
|
|
8634081c86 | ||
|
|
f7d2723fab | ||
|
|
1be97fe7fb | ||
|
|
71773c91c6 | ||
|
|
6e95c8b7a1 | ||
|
|
b36aff912a | ||
|
|
e4686d664b | ||
|
|
18a7876668 | ||
|
|
d48edeb5a9 | ||
|
|
a51d2a24d5 | ||
|
|
fc73592b69 | ||
|
|
60d453b0bb | ||
|
|
45886ccef1 | ||
|
|
84ef40ea8e | ||
|
|
512809f7c0 | ||
|
|
b85cd5376f | ||
|
|
1fcd18f0b0 | ||
|
|
dbea4f0194 | ||
|
|
75011610e5 | ||
|
|
109aca8229 | ||
|
|
3c1a72b038 | ||
|
|
3384eb1c47 | ||
|
|
e5f5bdb95c | ||
|
|
36e614f4f3 | ||
|
|
f5fc0545a4 | ||
|
|
69bf817f59 | ||
|
|
1c6914f5ce | ||
|
|
57bc340840 | ||
|
|
48387e5b2f | ||
|
|
1a7dc1acf5 | ||
|
|
d0fa5f3dbe | ||
|
|
f900d953a0 | ||
|
|
74e39b0b45 | ||
|
|
239202d9c1 | ||
|
|
85076919c6 | ||
|
|
9d0b52a242 | ||
|
|
f86d907c74 | ||
|
|
9966632c64 | ||
|
|
7aa0239cd9 | ||
|
|
7289872937 | ||
|
|
1f8b03ff6f | ||
|
|
e98f2b9605 | ||
|
|
92508b3f42 | ||
|
|
4f1a8a0a78 | ||
|
|
b5ce7f0cab | ||
|
|
778dda67d5 | ||
|
|
cd21865c4d | ||
|
|
0c4637f779 | ||
|
|
420038d5ff | ||
|
|
bdf67e732b | ||
|
|
f6d00d8d15 | ||
|
|
6072e8aa9a | ||
|
|
63c122e5e0 | ||
|
|
ed0a7fbdf5 | ||
|
|
b57645d382 | ||
|
|
f28112f057 | ||
|
|
436523139e | ||
|
|
0fa005a99c | ||
|
|
6a1e3891aa | ||
|
|
1629d02f3d | ||
|
|
594aad9d3a | ||
|
|
1d45f427e5 | ||
|
|
253ec15bc4 | ||
|
|
b146d11e2f | ||
|
|
bbc0b74b9d | ||
|
|
1b55d2dfbc | ||
|
|
ddfc5e6aa8 | ||
|
|
0e612f01f1 | ||
|
|
cd9d9ae8d2 | ||
|
|
2771376ecc | ||
|
|
eefcbcd3dd | ||
|
|
3fed26cfbe | ||
|
|
3f9a116b17 | ||
|
|
aeac15c63c | ||
|
|
abcd7c9975 | ||
|
|
9ce88abcc8 | ||
|
|
084917f02d | ||
|
|
69294a7c4c | ||
|
|
537850f414 | ||
|
|
e9f2fe910d | ||
|
|
5560abf459 | ||
|
|
ea2765f8c7 | ||
|
|
448e7c3a7d | ||
|
|
09b5019e95 | ||
|
|
6ab3cd77a7 | ||
|
|
970da664b2 | ||
|
|
552a7e30da | ||
|
|
4296765ad6 | ||
|
|
6ecd202607 | ||
|
|
da5ca02f81 | ||
|
|
0acad8e9fa | ||
|
|
84b950edc0 | ||
|
|
45cb977eb2 | ||
|
|
c4af4c42ab | ||
|
|
f9c83e14e5 | ||
|
|
29757db3a9 | ||
|
|
c88fcb2e2d | ||
|
|
8589eabfff | ||
|
|
b557c06b0a | ||
|
|
8701fb20d0 | ||
|
|
40b07c35d4 | ||
|
|
b05bbb8db5 | ||
|
|
34618edc13 | ||
|
|
5acd91bc8e | ||
|
|
bbea522499 | ||
|
|
139341b043 | ||
|
|
2e35aff903 | ||
|
|
c7765528b0 | ||
|
|
263c31c8b5 | ||
|
|
5b64655776 | ||
|
|
4e2706b421 | ||
|
|
c700aa938f | ||
|
|
57c00c1ea7 | ||
|
|
b4bac6a915 | ||
|
|
e70a4ecae6 | ||
|
|
17983837fc | ||
|
|
621db89328 | ||
|
|
77b2d9ea22 | ||
|
|
bdb3cb2025 | ||
|
|
bddea7ab24 | ||
|
|
8a9d794fec | ||
|
|
956ccb6a56 | ||
|
|
a1765c4ac2 | ||
|
|
9b73423d49 | ||
|
|
8a27c70625 | ||
|
|
2bcc3e3419 | ||
|
|
bbb683e637 | ||
|
|
29b97ef6d8 | ||
|
|
c9a150b1db | ||
|
|
e28dfadaf5 | ||
|
|
f1c335815f | ||
|
|
019622aab1 | ||
|
|
5e37a96bc0 | ||
|
|
8a7fa00164 | ||
|
|
2c403e8e13 | ||
|
|
577cbcc7fa | ||
|
|
e118501bf1 | ||
|
|
50c143d959 | ||
|
|
03cecb3355 | ||
|
|
53b7425f67 | ||
|
|
28090c30a7 | ||
|
|
fb9c878db4 | ||
|
|
9757e3ae0c | ||
|
|
ef5d019c18 | ||
|
|
04fa9f79e2 | ||
|
|
2768155bba | ||
|
|
7d3da1a1d2 | ||
|
|
85486a6719 | ||
|
|
bc4849ef55 | ||
|
|
1d7ef6d44f | ||
|
|
e156acc1ae | ||
|
|
e037ba48f1 | ||
|
|
034968e437 | ||
|
|
7e906e2d46 | ||
|
|
c8b2a57721 | ||
|
|
4e03c1efcf | ||
|
|
ff0b5ed441 | ||
|
|
d163e29459 | ||
|
|
ee5076146e | ||
|
|
8056187c3c | ||
|
|
a3042f8e1b | ||
|
|
282f28f2b9 | ||
|
|
7c0ddad653 | ||
|
|
53f6f59798 | ||
|
|
8956cb3915 | ||
|
|
baad19a2c9 | ||
|
|
3dfbb0e0a5 | ||
|
|
726f62b9b6 | ||
|
|
e5966bd3fb | ||
|
|
81c30347f6 | ||
|
|
c0ea2bd189 | ||
|
|
a397893217 | ||
|
|
12c5bbd778 | ||
|
|
c5be00eebf | ||
|
|
4eae02a7e1 | ||
|
|
b531a642f6 | ||
|
|
e4f9b8a984 | ||
|
|
7456bea2ae | ||
|
|
38c76b55e0 | ||
|
|
eedadb8884 | ||
|
|
3f9bfeb01a | ||
|
|
b9d14a9fd0 | ||
|
|
026795d4c9 | ||
|
|
ee0a7bd8c0 | ||
|
|
b658ddf5cf | ||
|
|
63d7e2b398 | ||
|
|
033ba9d41b | ||
|
|
06e941171a | ||
|
|
e270999745 | ||
|
|
9e5e4c22f5 | ||
|
|
fcd1dfc675 | ||
|
|
b14abbfe08 | ||
|
|
1e5f88f462 | ||
|
|
035534e9fe | ||
|
|
c97c96a30c | ||
|
|
b116281196 | ||
|
|
e4a50c33f1 | ||
|
|
59150f955f | ||
|
|
c0769d4f8f | ||
|
|
35c6bfc021 | ||
|
|
3ffd5ab0ee | ||
|
|
f54df214af | ||
|
|
2ad85d5fdc | ||
|
|
9d0d5b86aa | ||
|
|
b576f0734f | ||
|
|
42295e076a | ||
|
|
f338b34b74 | ||
|
|
5d40ae6e22 | ||
|
|
cb123cee83 | ||
|
|
d957730a39 | ||
|
|
77bf730f15 | ||
|
|
95c2a21697 | ||
|
|
6e9ac275e1 | ||
|
|
1ccafc0ebd | ||
|
|
7c192d54f4 | ||
|
|
cf7ebc488d | ||
|
|
e34fbf72fd | ||
|
|
bfe25d9755 | ||
|
|
3052f12c91 | ||
|
|
a3b00c3458 | ||
|
|
a7314f876e | ||
|
|
5b39911e02 | ||
|
|
ec44841356 | ||
|
|
43c989e423 | ||
|
|
9979941081 | ||
|
|
3a926b34b0 | ||
|
|
239f8e40c7 | ||
|
|
92be2ee42b | ||
|
|
d20bd530ed | ||
|
|
72cffd74a7 | ||
|
|
6c437c515f | ||
|
|
cfacb151b5 | ||
|
|
996bd9f14e | ||
|
|
3c5ae360fd | ||
|
|
20289b5960 | ||
|
|
b239598e64 | ||
|
|
e2b800000d | ||
|
|
e34482bc3f | ||
|
|
06261c1118 | ||
|
|
686fe6fdf8 | ||
|
|
5bb2d75fe1 | ||
|
|
c80a1fa261 | ||
|
|
6f3f24a1d7 | ||
|
|
71875efb48 | ||
|
|
2e724b5bd8 | ||
|
|
136d874cba | ||
|
|
f1dd5e7e12 | ||
|
|
67f8aef00d | ||
|
|
b5fd1d520c | ||
|
|
c43cd16eff | ||
|
|
3812989a1c | ||
|
|
1d7150c24e | ||
|
|
946bd2db5e | ||
|
|
0c6d4780c3 | ||
|
|
45a277964b | ||
|
|
ec5fb83678 | ||
|
|
86efcd82c3 | ||
|
|
f05d4a0d0b | ||
|
|
9342a40bbc | ||
|
|
20ca3f2f5f | ||
|
|
82ac76b054 | ||
|
|
b3313a7f4d | ||
|
|
6e2589178b | ||
|
|
4b0241c3b3 | ||
|
|
2f75c4c6ae | ||
|
|
21db3676d1 | ||
|
|
d0c830e80d | ||
|
|
660ada0b20 | ||
|
|
2d31ea940a | ||
|
|
3ebb7d087f | ||
|
|
5e352c201f | ||
|
|
e044146aa5 | ||
|
|
083a74a686 | ||
|
|
58dbdad2b2 | ||
|
|
32f4916c4a | ||
|
|
6d0f7d4b50 | ||
|
|
f90e43b01a | ||
|
|
9bb11afc6b | ||
|
|
059995e0ef | ||
|
|
c2a3e8ed52 | ||
|
|
4f94d227fc | ||
|
|
9aa5c12164 | ||
|
|
4789583d66 | ||
|
|
98570c920e | ||
|
|
f001fb3b33 | ||
|
|
e1accbcef0 | ||
|
|
ae9a1b3e94 | ||
|
|
0b055458d0 | ||
|
|
be650aae37 | ||
|
|
a42f3115d3 | ||
|
|
2da0da826a | ||
|
|
a3fe4e6321 | ||
|
|
421f4c17f0 | ||
|
|
e97228899a | ||
|
|
2bfab5e99c | ||
|
|
0483becbfa | ||
|
|
3a1f6d5706 | ||
|
|
6c1742e94c | ||
|
|
6167011ee5 | ||
|
|
bc399d95a0 | ||
|
|
e1032ff5df | ||
|
|
e80920faa1 | ||
|
|
f7c0769deb | ||
|
|
78444a9e64 |
@@ -225,7 +225,8 @@
|
||||
"profile": "https://github.com/ankarhem",
|
||||
"contributions": [
|
||||
"doc",
|
||||
"code"
|
||||
"code",
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -262,6 +263,117 @@
|
||||
"profile": "https://github.com/TheCatLady",
|
||||
"contributions": [
|
||||
"code",
|
||||
"translation",
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "chriscpritchard",
|
||||
"name": "Chris Pritchard",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/1839074?v=4",
|
||||
"profile": "https://github.com/chriscpritchard",
|
||||
"contributions": [
|
||||
"code",
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Tamberlox",
|
||||
"name": "Tamberlox",
|
||||
"avatar_url": "https://avatars3.githubusercontent.com/u/56069014?v=4",
|
||||
"profile": "https://github.com/Tamberlox",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "hmnd",
|
||||
"name": "David",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/12853597?v=4",
|
||||
"profile": "https://hmnd.io",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "douglasparker",
|
||||
"name": "Douglas Parker",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/18235822?v=4",
|
||||
"profile": "https://www.douglas-parker.com",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "dancarter",
|
||||
"name": "Daniel Carter",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/4387516?v=4",
|
||||
"profile": "https://github.com/dancarter",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "NuroDev",
|
||||
"name": "nuro",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/4991309?v=4",
|
||||
"profile": "https://nuro.dev",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "onedr0p",
|
||||
"name": "ᗪєνιη ᗷυнʟ",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/213795?v=4",
|
||||
"profile": "https://github.com/onedr0p",
|
||||
"contributions": [
|
||||
"infra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "JonnyWong16",
|
||||
"name": "JonnyWong16",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/9099342?v=4",
|
||||
"profile": "https://github.com/JonnyWong16",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Roxedus",
|
||||
"name": "Roxedus",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/7110194?v=4",
|
||||
"profile": "https://github.com/Roxedus",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "WoisWoi",
|
||||
"name": "WoisWoi",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/75491231?v=4",
|
||||
"profile": "https://github.com/WoisWoi",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "HubDuck",
|
||||
"name": "HubDuck",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/77843475?v=4",
|
||||
"profile": "https://github.com/HubDuck",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "costaht",
|
||||
"name": "costaht",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/50637431?v=4",
|
||||
"profile": "https://github.com/costaht",
|
||||
"contributions": [
|
||||
"doc",
|
||||
"translation"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
node_modules
|
||||
.next
|
||||
**/*.md
|
||||
**/.gitkeep
|
||||
**/.vscode
|
||||
.all-contributorsrc
|
||||
.dockerignore
|
||||
.editorconfig
|
||||
.eslintrc.js
|
||||
.git
|
||||
.gitbook.yaml
|
||||
.gitconfig
|
||||
.gitignore
|
||||
.github
|
||||
.all-contributorsrc
|
||||
.editorconfig
|
||||
.next
|
||||
.prettierignore
|
||||
**/README.md
|
||||
**/.vscode
|
||||
config/db/db.sqlite3
|
||||
config/db/logs/overseerr.log
|
||||
Dockerfil**
|
||||
**.md
|
||||
Dockerfile*
|
||||
docker-compose.yml
|
||||
docs
|
||||
LICENSE
|
||||
node_modules
|
||||
snap
|
||||
stylelint.config.js
|
||||
|
||||
@@ -32,6 +32,7 @@ module.exports = {
|
||||
'formatjs/no-offset': 'error',
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['error'],
|
||||
'jsx-a11y/no-onchange': 'off',
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
|
||||
12
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# Global code ownership
|
||||
* @sct
|
||||
|
||||
# Documentation
|
||||
docs/ @TheCatLady @samwiseg0
|
||||
|
||||
# Snap-related files
|
||||
.github/workflows/snap.yaml @samwiseg0
|
||||
snap/ @samwiseg0
|
||||
|
||||
# i18n locale files
|
||||
src/i18n/locale/ @sct @TheCatLady
|
||||
54
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,45 +1,45 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
about: Submit a report to help us improve
|
||||
title: ''
|
||||
labels: 'awaiting-triage, type:bug'
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
#### Description
|
||||
|
||||
**What version of Overseerr are you running?**
|
||||
Please fill in the version you are currently running.
|
||||
Please provide a clear and concise description of the bug or issue.
|
||||
|
||||
You can find it under: Settings -> About -> Version
|
||||
#### Version
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
What version of Overseerr are you running? (You can find this in Settings → About → Version.)
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
#### Steps to Reproduce
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
Please tell us how we can reproduce the undesired behavior.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
1. Go to [...]
|
||||
2. Click on [...]
|
||||
3. Scroll down to [...]
|
||||
4. See error in [...]
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
#### Expected Behavior
|
||||
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
Please provide a clear and concise description of what you expected to happen.
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
#### Screenshots
|
||||
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
If applicable, please provide screenshots depicting the problem.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
#### Device
|
||||
|
||||
What device were you using when you encountered this issue? Please provide this information to help us reproduce and investigate the bug.
|
||||
|
||||
- **Platform:** [e.g., desktop, smartphone, tablet]
|
||||
- **Device:** [e.g., iPhone X, Surface Pro, Samsung Galaxy Tab]
|
||||
- **OS:** [e.g., iOS 8.1, Windows 10, Android 11]
|
||||
- **Browser:** [e.g., Chrome, Safari, Edge, Firefox]
|
||||
|
||||
#### Additional Context
|
||||
|
||||
Please provide any additional information that may be relevant or helpful.
|
||||
|
||||
16
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -6,14 +6,14 @@ labels: 'awaiting-triage, type:enhancement'
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
#### Description
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
Is your feature request related to a problem? If so, please provide a clear and concise description of the problem. E.g., "I'm always frustrated when [...]."
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
#### Desired Behavior
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
Provide a clear and concise description of what you want to happen.
|
||||
|
||||
#### Additional Context
|
||||
|
||||
Provide any additional information or screenshots that may be relevant or helpful.
|
||||
|
||||
12
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,13 +1,13 @@
|
||||
#### Description
|
||||
|
||||
#### Screenshot (if UI related)
|
||||
#### Screenshot (if UI-related)
|
||||
|
||||
#### Todos
|
||||
#### To-Dos
|
||||
|
||||
- [ ] Sucessfully builds `yarn build`
|
||||
- [ ] Translation Keys `yarn i18n:extract`
|
||||
- [ ] Database migration created (if required)
|
||||
- [ ] Successful build `yarn build`
|
||||
- [ ] Translation keys `yarn i18n:extract`
|
||||
- [ ] Database migration (if required)
|
||||
|
||||
#### Issues Fixed or Closed by this PR
|
||||
#### Issues Fixed or Closed
|
||||
|
||||
- Fixes #XXXX
|
||||
|
||||
6
.github/dependabot.yml
vendored
@@ -6,3 +6,9 @@ updates:
|
||||
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
|
||||
|
||||
2
.github/stale.yml
vendored
@@ -8,7 +8,7 @@ exemptLabels:
|
||||
- security
|
||||
- dependencies
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: wontfix
|
||||
staleLabel: stale
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
|
||||
26
.github/workflows/ci.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
test:
|
||||
name: Lint & Test Build
|
||||
runs-on: ubuntu-20.04
|
||||
container: node:12.18-alpine
|
||||
container: node:14.16-alpine
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v2
|
||||
@@ -24,6 +24,7 @@ jobs:
|
||||
run: yarn lint
|
||||
- name: build
|
||||
run: yarn build
|
||||
|
||||
build_and_push:
|
||||
name: Build & Publish to Docker Hub
|
||||
needs: test
|
||||
@@ -36,6 +37,13 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v1
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2.1.4
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
@@ -52,6 +60,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: true
|
||||
build-args: |
|
||||
COMMIT_TAG=${{ github.sha }}
|
||||
@@ -60,14 +69,24 @@ jobs:
|
||||
sctx/overseerr:${{ github.sha }}
|
||||
ghcr.io/sct/overseerr:develop
|
||||
ghcr.io/sct/overseerr:${{ github.sha }}
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
||||
- # Temporary fix
|
||||
# https://github.com/docker/build-push-action/issues/252
|
||||
# https://github.com/moby/buildkit/issues/1896
|
||||
name: Move cache
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
|
||||
discord:
|
||||
name: Send Discord Notification
|
||||
needs: build_and_push
|
||||
if: always() && github.event_name != 'pull_request' && !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@v1
|
||||
|
||||
uses: technote-space/workflow-conclusion-action@v2.1.5
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
run: |
|
||||
@@ -77,7 +96,6 @@ jobs:
|
||||
else
|
||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
||||
fi
|
||||
|
||||
- name: Post Status to Discord
|
||||
uses: sarisia/actions-status-discord@v1
|
||||
with:
|
||||
|
||||
23
.github/workflows/deploy_docs.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: Deploy API Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Generate Swagger UI
|
||||
uses: Legion2/swagger-ui-action@v1
|
||||
with:
|
||||
output: swagger-ui
|
||||
spec-file: overseerr-api.yml
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: swagger-ui
|
||||
cname: api-docs.overseerr.dev
|
||||
6
.github/workflows/invalid_template.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
github-token: ${{ github.token }}
|
||||
support-label: 'invalid:template-incomplete'
|
||||
issue-comment: >
|
||||
:wave: @{issue-author}, please edit your issue and follow the template provided.
|
||||
close-issue: false
|
||||
lock-issue: false
|
||||
:wave: @{issue-author}, please follow the template provided.
|
||||
close-issue: true
|
||||
lock-issue: true
|
||||
issue-lock-reason: 'resolved'
|
||||
|
||||
84
.github/workflows/release.yml
vendored
@@ -7,8 +7,9 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Lint & Test Build
|
||||
runs-on: ubuntu-20.04
|
||||
container: node:12.18-alpine
|
||||
container: node:14.16-alpine
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v2
|
||||
@@ -30,9 +31,24 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v1
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 12
|
||||
node-version: 14
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.CR_PAT }}
|
||||
- name: Install dependencies
|
||||
run: yarn
|
||||
- name: Release
|
||||
@@ -41,13 +57,73 @@ jobs:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
run: npx semantic-release
|
||||
build-snap:
|
||||
name: Build Snap Package (${{ matrix.architecture }})
|
||||
needs: semantic-release
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
architecture:
|
||||
- amd64
|
||||
- arm64
|
||||
- armhf
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Switch to master branch
|
||||
run: git checkout master
|
||||
- name: Pull latest changes
|
||||
run: git pull
|
||||
|
||||
- name: Prepare
|
||||
id: prepare
|
||||
run: |
|
||||
git fetch --prune --tags
|
||||
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
||||
echo ::set-output name=RELEASE::stable
|
||||
else
|
||||
echo ::set-output name=RELEASE::edge
|
||||
fi
|
||||
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
with:
|
||||
image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
|
||||
|
||||
- name: Build Snap Package
|
||||
uses: diddlesnaps/snapcraft-multiarch-action@v1
|
||||
id: build
|
||||
with:
|
||||
architecture: ${{ matrix.architecture }}
|
||||
|
||||
- name: Upload Snap Package
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: overseerr-snap-package-${{ matrix.architecture }}
|
||||
path: ${{ steps.build.outputs.snap }}
|
||||
|
||||
- name: Review Snap Package
|
||||
uses: diddlesnaps/snapcraft-review-tools-action@v1.2.0
|
||||
with:
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
|
||||
- name: Publish Snap Package
|
||||
uses: snapcore/action-publish@v1
|
||||
with:
|
||||
store_login: ${{ secrets.SNAP_LOGIN }}
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
release: ${{ steps.prepare.outputs.RELEASE }}
|
||||
discord:
|
||||
name: Send Discord Notification
|
||||
needs: semantic-release
|
||||
if: always()
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v1
|
||||
uses: technote-space/workflow-conclusion-action@v2.1.5
|
||||
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
|
||||
23
.github/workflows/snap.yaml
vendored
@@ -3,14 +3,21 @@ name: Publish Snap
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
tags: [v*]
|
||||
pull_request: ~
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Lint & Test Build
|
||||
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.8.0
|
||||
with:
|
||||
access_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
test:
|
||||
name: Lint & Test Build
|
||||
needs: jobs
|
||||
runs-on: ubuntu-20.04
|
||||
container: node:12.18-alpine
|
||||
steps:
|
||||
- name: checkout
|
||||
@@ -50,6 +57,8 @@ jobs:
|
||||
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
with:
|
||||
image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
|
||||
|
||||
- name: Build Snap Package
|
||||
uses: diddlesnaps/snapcraft-multiarch-action@v1
|
||||
@@ -64,7 +73,7 @@ jobs:
|
||||
path: ${{ steps.build.outputs.snap }}
|
||||
|
||||
- name: Review Snap Package
|
||||
uses: diddlesnaps/snapcraft-review-tools-action@v1
|
||||
uses: diddlesnaps/snapcraft-review-tools-action@v1.2.0
|
||||
with:
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
|
||||
@@ -74,14 +83,14 @@ jobs:
|
||||
store_login: ${{ secrets.SNAP_LOGIN }}
|
||||
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@v1
|
||||
uses: technote-space/workflow-conclusion-action@v2.1.5
|
||||
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
|
||||
3
.github/workflows/support.yml
vendored
@@ -19,7 +19,8 @@ jobs:
|
||||
to get help with Overseerr.
|
||||
|
||||
- [Discord](https://discord.gg/PkCWJSeCk7)
|
||||
|
||||
- [GitHub Discussions](https://github.com/sct/overseerr/discussions)
|
||||
close-issue: true
|
||||
lock-issue: false
|
||||
lock-issue: true
|
||||
issue-lock-reason: 'off-topic'
|
||||
|
||||
5
.gitignore
vendored
@@ -38,6 +38,8 @@ config/settings.json
|
||||
# logs
|
||||
config/logs/*.log*
|
||||
config/logs/*.json
|
||||
config/logs/*.log.gz
|
||||
config/logs/*-audit.json
|
||||
|
||||
# anidb mapping file
|
||||
config/anime-list.xml
|
||||
@@ -47,3 +49,6 @@ dist
|
||||
|
||||
# sqlite journal
|
||||
config/db/db.sqlite3-journal
|
||||
|
||||
# VS Code
|
||||
.vscode/launch.json
|
||||
|
||||
9
.stoplight.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"formats": {
|
||||
"openapi": {
|
||||
"rootDir": ".",
|
||||
"include": ["**"]
|
||||
}
|
||||
},
|
||||
"exclude": ["docs"]
|
||||
}
|
||||
5
.vscode/extensions.json
vendored
@@ -19,6 +19,9 @@
|
||||
|
||||
"stylelint.vscode-stylelint",
|
||||
|
||||
"bradlc.vscode-tailwindcss"
|
||||
"bradlc.vscode-tailwindcss",
|
||||
|
||||
// https://marketplace.visualstudio.com/items?itemName=heybourn.headwind
|
||||
"heybourn.headwind"
|
||||
]
|
||||
}
|
||||
|
||||
269
CHANGELOG.md
@@ -1,3 +1,272 @@
|
||||
## [1.21.1](https://github.com/sct/overseerr/compare/v1.21.0...v1.21.1) (2021-03-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **lang:** translations update from Weblate ([#1155](https://github.com/sct/overseerr/issues/1155)) ([ebc285c](https://github.com/sct/overseerr/commit/ebc285c758f69846e4a5cb74bb42ca5924d166d4))
|
||||
|
||||
# [1.21.0](https://github.com/sct/overseerr/compare/v1.20.1...v1.21.0) (2021-03-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* do not allow editing of user settings under certain conditions ([#1168](https://github.com/sct/overseerr/issues/1168)) ([001dcd3](https://github.com/sct/overseerr/commit/001dcd328c8d3b1c417fd7c7ee2aa20183b08eef))
|
||||
* **frontend:** check for ID instead of email after initial setup Plex login ([#1097](https://github.com/sct/overseerr/issues/1097)) ([778dda6](https://github.com/sct/overseerr/commit/778dda67d54df87347dd79577ef1bdc88d3c1d3f))
|
||||
* **frontend:** check if swr is validating to determine if we should fetch new data ([e5f5bdb](https://github.com/sct/overseerr/commit/e5f5bdb95c62eba31a3321a7457d354f0226bf85)), closes [#719](https://github.com/sct/overseerr/issues/719)
|
||||
* **frontend:** never hide available content in search results ([d48edeb](https://github.com/sct/overseerr/commit/d48edeb5a9bd8e2edce8bca0fea50e300bb7a1ae))
|
||||
* **lang:** add missing i18n strings ([6072e8a](https://github.com/sct/overseerr/commit/6072e8aa9a0f84e50c44a92af303aad15b5f3021))
|
||||
* **lang:** edit new Telegram-related strings to conform to style guide ([#1093](https://github.com/sct/overseerr/issues/1093)) ([bdf67e7](https://github.com/sct/overseerr/commit/bdf67e732b6c77cbae768a25edfc9a663ef0108b))
|
||||
* **notif:** loosen input validation on Pushover settings ([#1166](https://github.com/sct/overseerr/issues/1166)) ([3148d31](https://github.com/sct/overseerr/commit/3148d312141248653c5d1e42cd2882a67a339163))
|
||||
* **notif:** set URL for Discord embeds rather than adding a field for the link ([#1167](https://github.com/sct/overseerr/issues/1167)) ([0bd0912](https://github.com/sct/overseerr/commit/0bd0912613f0db24bd0da4ec956b5119133e35d4))
|
||||
* correctly send auto-approval notifictions for series ([8634081](https://github.com/sct/overseerr/commit/8634081c869a2078793ecf06b1b7e249bba0a2f8))
|
||||
* **lang:** fix singular form of season count ([#1080](https://github.com/sct/overseerr/issues/1080)) ([b57645d](https://github.com/sct/overseerr/commit/b57645d382361c856281e7a74295afe16c5390f2))
|
||||
* **requests:** add plex url to request item ([#1088](https://github.com/sct/overseerr/issues/1088)) ([420038d](https://github.com/sct/overseerr/commit/420038d5ffdd4070df03e5c5cb6ef8d6208fddb5))
|
||||
* **sonarr:** correctly search when updating existing sonarr series ([ed0a7fb](https://github.com/sct/overseerr/commit/ed0a7fbdf5122a26fa936e83b76a97c55781782d)), closes [#588](https://github.com/sct/overseerr/issues/588)
|
||||
* **ui:** add alt prop to studio/network logos & fix blinking text cursor ([#1095](https://github.com/sct/overseerr/issues/1095)) ([0c4637f](https://github.com/sct/overseerr/commit/0c4637f779d8904037b9cbd5fe9166cf05a891c5))
|
||||
* **ui:** add link to poster image on request items ([7289872](https://github.com/sct/overseerr/commit/7289872937d5bb94d027424760ee1ceb94095604))
|
||||
* **ui:** correct language usage re: "sync" vs. "scan" ([#1079](https://github.com/sct/overseerr/issues/1079)) ([e98f2b9](https://github.com/sct/overseerr/commit/e98f2b96058fb9c5af77be2e8a1bd07fb8fcca06))
|
||||
* **ui:** display "Season" vs. "Seasons" as appropriate, and fix request block "Seasons" formatting ([#1127](https://github.com/sct/overseerr/issues/1127)) ([45886cc](https://github.com/sct/overseerr/commit/45886ccef1bee57dc555060a491834567e45b59c))
|
||||
* **ui:** request list button sizes ([#1152](https://github.com/sct/overseerr/issues/1152)) ([fc73592](https://github.com/sct/overseerr/commit/fc73592b69c38191f91a68a020868b8e5ec2e2e2))
|
||||
* fix language filter link on movie detail pages ([#1142](https://github.com/sct/overseerr/issues/1142)) ([60d453b](https://github.com/sct/overseerr/commit/60d453b0bbba5e2060f72f40d1dde85ec6b05af4))
|
||||
* remove language/region filtering on studio/network results ([#1129](https://github.com/sct/overseerr/issues/1129)) ([109aca8](https://github.com/sct/overseerr/commit/109aca8229dc7b81cac314d84591f1c04c12ac2e))
|
||||
* **api:** check correct permissions for auto approve when requests are created ([3c1a72b](https://github.com/sct/overseerr/commit/3c1a72b038fd178b4be4dc082cd1496474148d7e))
|
||||
* **frontend:** status, requested by, and modified alignment fix ([#1109](https://github.com/sct/overseerr/issues/1109)) ([1a7dc1a](https://github.com/sct/overseerr/commit/1a7dc1acf57888d3d0285b58c1c97a824a232216))
|
||||
* **ui:** don't show "Password" user settings tab if current user lacks perms to modify the password ([#1063](https://github.com/sct/overseerr/issues/1063)) ([b146d11](https://github.com/sct/overseerr/commit/b146d11e2ffecedae76472b0491a4662ca4a4a4e))
|
||||
* **ui:** fix Radarr logo alignment ([#1068](https://github.com/sct/overseerr/issues/1068)) ([0fa005a](https://github.com/sct/overseerr/commit/0fa005a99cd868b5a235ae9ce65b4c64b05d0f47))
|
||||
* **ui:** fix request list UI behavior when season list is too long ([#1106](https://github.com/sct/overseerr/issues/1106)) ([8507691](https://github.com/sct/overseerr/commit/85076919c6ccbf052699b7d5f4ba8b6e5e5af74d))
|
||||
* **ui:** improve responsive design on new request list UI ([#1105](https://github.com/sct/overseerr/issues/1105)) ([1f8b03f](https://github.com/sct/overseerr/commit/1f8b03ff6f67ce76051667de05166da54ed3dc89))
|
||||
* **ui:** list all movie studios instead of just the first result ([#1110](https://github.com/sct/overseerr/issues/1110)) ([239202d](https://github.com/sct/overseerr/commit/239202d9c11f27410b0fa084bcc4c824b7136081))
|
||||
* add correct permission checks to modifying user password/permissions ([ddfc5e6](https://github.com/sct/overseerr/commit/ddfc5e6aa8fc636931f495d6f23d56367466e3b5))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add tagline, episode runtime, genres list to media details & clean/refactor CSS into globals ([#1160](https://github.com/sct/overseerr/issues/1160)) ([2f2e002](https://github.com/sct/overseerr/commit/2f2e00237d43bdab85bfadc3c4f2fbcdde4c2e90))
|
||||
* **docker:** add tini to docker image ([#1017](https://github.com/sct/overseerr/issues/1017)) ([1629d02](https://github.com/sct/overseerr/commit/1629d02f3d8368bfd5f6fed05382974ae6fce51f))
|
||||
* **email:** add pgp support ([#1138](https://github.com/sct/overseerr/issues/1138)) ([9e5adeb](https://github.com/sct/overseerr/commit/9e5adeb610bdc4800ff536412d0ae8a11fb4338d))
|
||||
* **frontend:** add loading bar indicator ([#1170](https://github.com/sct/overseerr/issues/1170)) ([3d6b343](https://github.com/sct/overseerr/commit/3d6b3434138fec49c58f2bf74f781d5e2fc2911f))
|
||||
* **lang:** localize job names ([#1043](https://github.com/sct/overseerr/issues/1043)) ([594aad9](https://github.com/sct/overseerr/commit/594aad9d3ae9b323677f3af8c434d7664526593d))
|
||||
* **lang:** translations update from Weblate ([#1051](https://github.com/sct/overseerr/issues/1051)) ([69bf817](https://github.com/sct/overseerr/commit/69bf817f598babed99964f073259f827b60bd014))
|
||||
* **lang:** Translations update from Weblate ([#1131](https://github.com/sct/overseerr/issues/1131)) ([e4686d6](https://github.com/sct/overseerr/commit/e4686d664b52448e32488ff1c4236f72e01e9a29))
|
||||
* **notif:** add "Media Automatically Approved" notification type ([#1137](https://github.com/sct/overseerr/issues/1137)) ([f7d2723](https://github.com/sct/overseerr/commit/f7d2723fab2c30564fd23945709cd39b178a6eef))
|
||||
* **notif:** add settings for Discord bot username & avatar URL ([#1113](https://github.com/sct/overseerr/issues/1113)) ([3384eb1](https://github.com/sct/overseerr/commit/3384eb1c479114c0246cb22f9a933aa79fb95fcf))
|
||||
* **notif:** include poster image in Telegram notifications ([#1112](https://github.com/sct/overseerr/issues/1112)) ([48387e5](https://github.com/sct/overseerr/commit/48387e5b2f26c0c33acd436c6e1cf902d6c32101))
|
||||
* **scan:** add support for new plex tv agent ([#1144](https://github.com/sct/overseerr/issues/1144)) ([a51d2a2](https://github.com/sct/overseerr/commit/a51d2a24d51d092a0c6da608e3322f19a37c2d28))
|
||||
* **ui:** add user ID to profile header ([6e95c8b](https://github.com/sct/overseerr/commit/6e95c8b7a10e3467bfd2c3df84ccf886fe01ca5c))
|
||||
* add genre/studio/network view to Discover results ([#1067](https://github.com/sct/overseerr/issues/1067)) ([f28112f](https://github.com/sct/overseerr/commit/f28112f057df2589f31ae0d0b14e8b50e479fdb7))
|
||||
* add language-filtered Discover pages ([#1111](https://github.com/sct/overseerr/issues/1111)) ([7501161](https://github.com/sct/overseerr/commit/75011610e57f03098c8be9375d0c9ba1e3647e9b))
|
||||
* add studio/network sliders to discover ([1c6914f](https://github.com/sct/overseerr/commit/1c6914f5ce5c0d171c4609813915b50233a8e3ad))
|
||||
* **telegram:** add support for individual chat notifications ([#1027](https://github.com/sct/overseerr/issues/1027)) ([f6d00d8](https://github.com/sct/overseerr/commit/f6d00d8d1559879189f83739193c6e2acafde51d))
|
||||
* **ui:** display "Owner" role instead of "Admin" for user ID 1 ([#1050](https://github.com/sct/overseerr/issues/1050)) ([1b55d2d](https://github.com/sct/overseerr/commit/1b55d2dfbc06d900e7370a4ddfd81789a25bf00c))
|
||||
* **ui:** display season count on TV details page ([#1078](https://github.com/sct/overseerr/issues/1078)) ([4365231](https://github.com/sct/overseerr/commit/436523139e8f1594c352b17032734b4498d3994f))
|
||||
* **ui:** in Settings > Services, make Radarr/Sonarr server names and logos clickable links ([#1008](https://github.com/sct/overseerr/issues/1008)) ([6a1e389](https://github.com/sct/overseerr/commit/6a1e3891aa5f84b6adb1e475a6658a8cd4e34c22))
|
||||
* **ui:** request list redesign ([#1099](https://github.com/sct/overseerr/issues/1099)) ([cd21865](https://github.com/sct/overseerr/commit/cd21865c4d5be00c13c372e0b7a058f61ec855a2))
|
||||
|
||||
## [1.20.1](https://github.com/sct/overseerr/compare/v1.20.0...v1.20.1) (2021-02-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **notif:** escape application title in Telegram notifications ([#1012](https://github.com/sct/overseerr/issues/1012)) ([5560abf](https://github.com/sct/overseerr/commit/5560abf459b0350ff30b5e71d4208418fc8f3b3e))
|
||||
* **notif:** fixed typo in pushover hint ([#1029](https://github.com/sct/overseerr/issues/1029)) ([e9f2fe9](https://github.com/sct/overseerr/commit/e9f2fe910d72fa41bc27673ed43291211c3cac65))
|
||||
* **notifications:** correctly send tv auto approval notifications ([537850f](https://github.com/sct/overseerr/commit/537850f414a88df24c78794a2fd68e1e24ff73d1)), closes [#1041](https://github.com/sct/overseerr/issues/1041)
|
||||
* **plex-sync:** no longer incorrectly sets 4k availability when there isnt any ([3f9a116](https://github.com/sct/overseerr/commit/3f9a116b17d78eeb04f0f125a4f3af6f907c83dd)), closes [#990](https://github.com/sct/overseerr/issues/990)
|
||||
* **ui:** for server default options, display "All" region/language option instead of empty string ([#1042](https://github.com/sct/overseerr/issues/1042)) ([3fed26c](https://github.com/sct/overseerr/commit/3fed26cfbe74cb662ca531fd37b69f159a051ac1))
|
||||
* **ui:** show translated string on sonarr sucesss/failure toast messages ([#1035](https://github.com/sct/overseerr/issues/1035)) ([eefcbcd](https://github.com/sct/overseerr/commit/eefcbcd3ddfa5258ee24dbbbd79de5bf50310f27))
|
||||
* **ui:** use country-flag-icons instead of country-flag-emoji for RegionSelector ([#1011](https://github.com/sct/overseerr/issues/1011)) ([abcd7c9](https://github.com/sct/overseerr/commit/abcd7c997584c1310bd8b313ac38f30e335af8d7))
|
||||
* add missing default value for settings context ([084917f](https://github.com/sct/overseerr/commit/084917f02d399e2d29bb9927e033c2e6533f586c))
|
||||
* added missing language default for ssr context defaults ([9ce88ab](https://github.com/sct/overseerr/commit/9ce88abcc85d744d77172cd2357fdb4ff60dc5e4))
|
||||
* allow users to override language/region settings ([69294a7](https://github.com/sct/overseerr/commit/69294a7c4c5bbe55c5cd276786cdfd48ddbff889)), closes [#1013](https://github.com/sct/overseerr/issues/1013)
|
||||
|
||||
# [1.20.0](https://github.com/sct/overseerr/compare/v1.19.1...v1.20.0) (2021-02-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** add isAuthenticated middleware to base user route ([8a27c70](https://github.com/sct/overseerr/commit/8a27c7062599ea23dca115e6e6e95a594e1b219a))
|
||||
* **api:** sort users requests by most recent ([1798383](https://github.com/sct/overseerr/commit/17983837fc10661a59d29fc1531530fca0d77825))
|
||||
* **api:** Use POST instead of GET for API endpoints that mutate state ([#877](https://github.com/sct/overseerr/issues/877)) ([ff0b5ed](https://github.com/sct/overseerr/commit/ff0b5ed44132cc5a0cd178035796d042ba735a8d))
|
||||
* **auth:** handle sign-in attempts from emails with no password ([#933](https://github.com/sct/overseerr/issues/933)) ([5e37a96](https://github.com/sct/overseerr/commit/5e37a96bc017471f8dc4cbdd57f2e8c3568bd97f))
|
||||
* **frontend:** changed plex, request, and cog buttons to align properly on smaller mobile UIs ([#928](https://github.com/sct/overseerr/issues/928)) ([f1c3358](https://github.com/sct/overseerr/commit/f1c335815f2f17465cdd36ceb223e78a58149b3b))
|
||||
* **frontend:** check for id instead of email after logging in ([c4af4c4](https://github.com/sct/overseerr/commit/c4af4c42ab00f1a63a2f5326c9cd8b26c19f4f14))
|
||||
* **frontend:** Do not allow user w/ ID 1 to disable 'Admin' permission ([#965](https://github.com/sct/overseerr/issues/965)) ([77b2d9e](https://github.com/sct/overseerr/commit/77b2d9ea22a2f70cff58ac9421f3f6231bc93059))
|
||||
* **frontend:** handle empty array of media attributes ([#922](https://github.com/sct/overseerr/issues/922)) ([04fa9f7](https://github.com/sct/overseerr/commit/04fa9f79e2ec90082b3fa15590dd170f7d68ad52))
|
||||
* **frontend:** request and cog button would be misaligned without play on plex/watch trailer button ([#956](https://github.com/sct/overseerr/issues/956)) ([e28dfad](https://github.com/sct/overseerr/commit/e28dfadaf57d47887013c31dc5006332473156e3))
|
||||
* **frontend:** Update AdvancedRequester to reflect new /user API response ([#970](https://github.com/sct/overseerr/issues/970)) ([b4bac6a](https://github.com/sct/overseerr/commit/b4bac6a9157119a4f234933245944e133c127bd0))
|
||||
* **frontend:** use region settings instead of hardcoded 'US' value for movie/TV ratings ([#1006](https://github.com/sct/overseerr/issues/1006)) ([6ecd202](https://github.com/sct/overseerr/commit/6ecd202607cb48d559440da810ecc585e740542b))
|
||||
* **lang:** formatMessage should not use an object spread ([8a7fa00](https://github.com/sct/overseerr/commit/8a7fa00164fd5c5501da525baa29be97bac7e7c4))
|
||||
* **lang:** Remove unused strings and correct spelling of 'canceling'/'canceled' ([#981](https://github.com/sct/overseerr/issues/981)) ([5b64655](https://github.com/sct/overseerr/commit/5b646557765d1ad75e44e1c0e60e0291313c7746))
|
||||
* **login:** fix the gap when 'use your overseer account' was selected ([#870](https://github.com/sct/overseerr/issues/870)) ([d163e29](https://github.com/sct/overseerr/commit/d163e294599c4bd9bdc0a148db15c8e8541410d8))
|
||||
* **notif:** Do not HTML-escape email subjects ([#931](https://github.com/sct/overseerr/issues/931)) ([019622a](https://github.com/sct/overseerr/commit/019622aab1b94cc4d71cacbf0dc5cf64b62c8623))
|
||||
* **notif:** Remove extra newlines from Telegram notifications ([#973](https://github.com/sct/overseerr/issues/973)) ([bbea522](https://github.com/sct/overseerr/commit/bbea52249950eb98a8d3886f2bd7648a7d669bf4))
|
||||
* **plex:** Check Plex server access on user import ([#955](https://github.com/sct/overseerr/issues/955)) ([bdb3cb2](https://github.com/sct/overseerr/commit/bdb3cb202550e34d8951ac2b5015f97f6a5c1ebf))
|
||||
* **plex-sync:** get correct Plex metadata for Hama movie items ([#901](https://github.com/sct/overseerr/issues/901)) ([03cecb3](https://github.com/sct/overseerr/commit/03cecb33559e27199c5a174fc86de0c4550fe666)), closes [#898](https://github.com/sct/overseerr/issues/898)
|
||||
* **requests:** correctly filter requests out for users without view requests permission ([e118501](https://github.com/sct/overseerr/commit/e118501bf1dfa8dada2c57090e62631de620f3dd))
|
||||
* **requests:** correctly handle when tvdbid is missing ([#891](https://github.com/sct/overseerr/issues/891)) ([e037ba4](https://github.com/sct/overseerr/commit/e037ba48f173c06b0c9c8b03085edf832d770c06))
|
||||
* **search:** Handle search errors and escape * ([#893](https://github.com/sct/overseerr/issues/893)) ([034968e](https://github.com/sct/overseerr/commit/034968e4370eaea726c94730274349c083856813))
|
||||
* **services:** update all radarr/sonarr endpoints to use v3 ([da5ca02](https://github.com/sct/overseerr/commit/da5ca02f81fe91070afbda3e1ebc8d869fe39a8f))
|
||||
* **sonarr:** use qualityProfileId instad of profileId when adding series ([552a7e3](https://github.com/sct/overseerr/commit/552a7e30da5fc2cc0bd43b5aef79a0225c75d233))
|
||||
* **sync:** fix sonarr/plex sync fighting over availability ([9b73423](https://github.com/sct/overseerr/commit/9b73423d49e1e799cd82764a9ade8c75d92a28a2)), closes [#872](https://github.com/sct/overseerr/issues/872)
|
||||
* **ui:** add fallback for region display name ([f9c83e1](https://github.com/sct/overseerr/commit/f9c83e14e52a57d6865307b3324a61c04a77a541))
|
||||
* **ui:** add missing string for default Discover Language & edit string for default Discover Region ([#1004](https://github.com/sct/overseerr/issues/1004)) ([0acad8e](https://github.com/sct/overseerr/commit/0acad8e9fa65a9de6cecac9b6a4a5b2313ba8f06))
|
||||
* **ui:** Add tip & validation for Discord ID input ([#966](https://github.com/sct/overseerr/issues/966)) ([e70a4ec](https://github.com/sct/overseerr/commit/e70a4ecae613e045977e262fd7f9643f30985ab7))
|
||||
* **ui:** also allow 17 digit discord ids ([57c00c1](https://github.com/sct/overseerr/commit/57c00c1ea71c1229d5a59e1b8dadd84a646772b9)), closes [#971](https://github.com/sct/overseerr/issues/971)
|
||||
* **ui:** Automatically disable and uncheck user permissions with unmet requirements ([#941](https://github.com/sct/overseerr/issues/941)) ([c9a150b](https://github.com/sct/overseerr/commit/c9a150b1db2adbb305cf1a448489d7a8c14cf1cb))
|
||||
* **ui:** change font size in request list/user list dropdowns to prevent zoom on mobile ([fb9c878](https://github.com/sct/overseerr/commit/fb9c878db49c01d13773e8d2f94c93f840be0b82))
|
||||
* **ui:** Display 4K download status on 4K status badge ([#988](https://github.com/sct/overseerr/issues/988)) ([40b07c3](https://github.com/sct/overseerr/commit/40b07c35d40c03039e4bfa5ed1e73af7e8aa6a7d))
|
||||
* **ui:** Fix card sizes on person detail pages ([#881](https://github.com/sct/overseerr/issues/881)) ([a3042f8](https://github.com/sct/overseerr/commit/a3042f8e1b05a91d98f48a4aecb08e831a48fc56))
|
||||
* **ui:** Fix settings navigation horizontal scroll issues ([#987](https://github.com/sct/overseerr/issues/987)) ([8701fb2](https://github.com/sct/overseerr/commit/8701fb20d07773f4cc32e857b68575a813cf7e21))
|
||||
* **ui:** fix webhook URL validation regex ([baad19a](https://github.com/sct/overseerr/commit/baad19a2c94728313ee996fe1a0ffc64fbd9aaa3))
|
||||
* **ui:** fixed anime language profile typo ([#879](https://github.com/sct/overseerr/issues/879)) ([ee50761](https://github.com/sct/overseerr/commit/ee5076146ef3c5e8baba197a5b397d3c3f575262))
|
||||
* **ui:** Handle missing movie/series data ([#862](https://github.com/sct/overseerr/issues/862)) ([7c0ddad](https://github.com/sct/overseerr/commit/7c0ddad653393327226a877692f046d8693ddc66))
|
||||
* **ui:** Notification-related string/UI edits and field validation ([#985](https://github.com/sct/overseerr/issues/985)) ([c88fcb2](https://github.com/sct/overseerr/commit/c88fcb2e2d1c4b84527844a80680c15337626e72))
|
||||
* **ui:** rename global group class to form-group ([8056187](https://github.com/sct/overseerr/commit/8056187c3c0ea464a8f751aa6347ea1d35c01aac))
|
||||
* **ui:** Size cards appropriately based on base font size ([#871](https://github.com/sct/overseerr/issues/871)) ([282f28f](https://github.com/sct/overseerr/commit/282f28f2b9d0cc8c9105d01b43d4e1f730320b8b))
|
||||
* **ui/notif:** Custom application title in password-related emails and UI messages ([#979](https://github.com/sct/overseerr/issues/979)) ([4e2706b](https://github.com/sct/overseerr/commit/4e2706b4211b06f364910c327d84c2ceb45b2fe3))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **lang:** translated using Weblate (French) ([#1007](https://github.com/sct/overseerr/issues/1007)) ([970da66](https://github.com/sct/overseerr/commit/970da664b2700b8cd9ad8dce0cbca1d37820eceb))
|
||||
* **lang:** translations update from Weblate ([#853](https://github.com/sct/overseerr/issues/853)) ([e156acc](https://github.com/sct/overseerr/commit/e156acc1ae2fa86b4441faacc0b58e1e993e0edc))
|
||||
* **lang:** translations update from Weblate ([#986](https://github.com/sct/overseerr/issues/986)) ([4296765](https://github.com/sct/overseerr/commit/4296765ad61bac09c2317b71b763366d328733e4))
|
||||
* **notif:** Add Pushbullet notification agent ([#950](https://github.com/sct/overseerr/issues/950)) ([29b97ef](https://github.com/sct/overseerr/commit/29b97ef6d85bbea31dd59b7ad857b0d8ab30bff0))
|
||||
* **notif:** Notification improvements ([#914](https://github.com/sct/overseerr/issues/914)) ([2768155](https://github.com/sct/overseerr/commit/2768155bbabe121a4c51fc1472461cd5114c4300))
|
||||
* **regions:** add region/original language setting for filtering Discover ([#732](https://github.com/sct/overseerr/issues/732)) ([#942](https://github.com/sct/overseerr/issues/942)) ([b557c06](https://github.com/sct/overseerr/commit/b557c06b0a78f5df5f64a05dc1e4511dae72df4f))
|
||||
* **requests:** add language profile support ([#860](https://github.com/sct/overseerr/issues/860)) ([53f6f59](https://github.com/sct/overseerr/commit/53f6f59798fa7e3f95959990a3df555db3c1c51e))
|
||||
* **ui:** Add 'Available' filter to request list and remove unused MediaRequestStatus.AVAILABLE enum value ([#905](https://github.com/sct/overseerr/issues/905)) ([9757e3a](https://github.com/sct/overseerr/commit/9757e3ae0c572fb46177e25154b29e0ceced665f))
|
||||
* **ui:** Add 'Page Size' setting for request/user list pages ([#957](https://github.com/sct/overseerr/issues/957)) ([621db89](https://github.com/sct/overseerr/commit/621db893281f0280fe773ac7dbdc44434895242c))
|
||||
* **ui:** Add separate permissions for 4K auto approval ([#908](https://github.com/sct/overseerr/issues/908)) ([53b7425](https://github.com/sct/overseerr/commit/53b7425f6711e250935e7bb024c38ff6c62e07d9))
|
||||
* **ui:** Add sort options to user list ([#913](https://github.com/sct/overseerr/issues/913)) ([ef5d019](https://github.com/sct/overseerr/commit/ef5d019c18d7f6cdbbb1e1b7f8ff7816ed9b117b))
|
||||
* **ui:** Add support for requesting collections in 4K ([#968](https://github.com/sct/overseerr/issues/968)) ([139341b](https://github.com/sct/overseerr/commit/139341b0434b41e7c31af36baacd8d65566a6a0c))
|
||||
* user profile/settings pages ([#958](https://github.com/sct/overseerr/issues/958)) ([bbb683e](https://github.com/sct/overseerr/commit/bbb683e637386ad8bbeb44dca97aac9cdaf11349))
|
||||
* **ui:** added content ratings for tv shows and movie ratings ([#878](https://github.com/sct/overseerr/issues/878)) ([c8b2a57](https://github.com/sct/overseerr/commit/c8b2a57721a51adcc7f90ec1acb48b127991d467))
|
||||
* **users:** add reset password flow ([#772](https://github.com/sct/overseerr/issues/772)) ([e5966bd](https://github.com/sct/overseerr/commit/e5966bd3fbfe172f264f4e986ad2aecf29ae1510))
|
||||
|
||||
## [1.19.1](https://github.com/sct/overseerr/compare/v1.19.0...v1.19.1) (2021-02-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ui:** Fix webhook URL validation regex ([#864](https://github.com/sct/overseerr/issues/864)) ([726f62b](https://github.com/sct/overseerr/commit/726f62b9b69b5078e718f129e26abdf358f5cb06))
|
||||
|
||||
# [1.19.0](https://github.com/sct/overseerr/compare/v1.18.0...v1.19.0) (2021-02-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** filter out adult content from combined credits ([3052f12](https://github.com/sct/overseerr/commit/3052f12c91b3ce86128324e3698fff61bbce3f2a))
|
||||
* **cache:** use formatted numbers for displaying cache counts ([6c437c5](https://github.com/sct/overseerr/commit/6c437c515fc01b9fe4461968875e23542bae7542))
|
||||
* **email:** make image a link to the action url in request template ([ee0a7bd](https://github.com/sct/overseerr/commit/ee0a7bd8c0b3a79c292b0abceb2f780f3889e49f)), closes [#834](https://github.com/sct/overseerr/issues/834)
|
||||
* **frontend:** add github sponsor link to about page ([7c192d5](https://github.com/sct/overseerr/commit/7c192d54f422a5f2b55750535d2382e313f1d011))
|
||||
* **frontend:** correctly show 4k download tracker activity ([a7314f8](https://github.com/sct/overseerr/commit/a7314f876ea528fdec0fb0a2adaa36a01afcdf38))
|
||||
* **frontend:** fix possible division by zero in download status ([#839](https://github.com/sct/overseerr/issues/839)) ([c97c96a](https://github.com/sct/overseerr/commit/c97c96a30c50db7735f06c6d2d2f6193fb7da55e))
|
||||
* **frontend:** match request button color on titlecards to other request buttons ([5b39911](https://github.com/sct/overseerr/commit/5b39911e024513fab7a62948e653cee08fd166c7))
|
||||
* **frontend:** set 4k status on RequestItem when request is for 4k ([a3b00c3](https://github.com/sct/overseerr/commit/a3b00c3458b868506d4158fb24f0369fa5daefc5))
|
||||
* **frontend:** use consistent spinner style on TitleCard/Plex Presets ([cf7ebc4](https://github.com/sct/overseerr/commit/cf7ebc488db33725444c428b4244d780ab9d123b))
|
||||
* **html:** th elements should be nested under tr, not directly under thead ([#801](https://github.com/sct/overseerr/issues/801)) ([6e9ac27](https://github.com/sct/overseerr/commit/6e9ac275e19d56de8c7a366db970c7321f26fc8a))
|
||||
* **lang:** Add missing source strings & remove local user sign-in setting tip ([#828](https://github.com/sct/overseerr/issues/828)) ([c0769d4](https://github.com/sct/overseerr/commit/c0769d4f8f2bad88e4638d8c3cbcc0414b3ef6fb))
|
||||
* **lang:** Edit English language strings ([#820](https://github.com/sct/overseerr/issues/820)) ([f54df21](https://github.com/sct/overseerr/commit/f54df214af86d90ea8d7cfcd4e39022215c3568c))
|
||||
* **lang:** translate language names & change zh-Hant language code to zh-TW ([#793](https://github.com/sct/overseerr/issues/793)) ([3c5ae36](https://github.com/sct/overseerr/commit/3c5ae360fd179d794a78cc918fe97a09216ca6b2))
|
||||
* **notif/ui:** Use custom application title in notifications & sign-in page ([#849](https://github.com/sct/overseerr/issues/849)) ([38c76b5](https://github.com/sct/overseerr/commit/38c76b55e0039c489cb6a4a0a298aa6385406db4))
|
||||
* **radarr:** correctly set requested status after sending to radarr (with auto approve) ([ec44841](https://github.com/sct/overseerr/commit/ec448413569ddc2f24bb856d29084169979f9f05))
|
||||
* **sonarr-sync:** sonarr sync will no longer set shows with no episodes to partially available ([d20bd53](https://github.com/sct/overseerr/commit/d20bd530edaadc5887b0361358da80153e36505c)), closes [#796](https://github.com/sct/overseerr/issues/796)
|
||||
* **ui:** Add additional URL & email input validation ([#843](https://github.com/sct/overseerr/issues/843)) ([3f9bfeb](https://github.com/sct/overseerr/commit/3f9bfeb01a67b2b587c7548b02ee826722e65c0f))
|
||||
* **ui:** Don't display empty dropdown when no trailer available ([#804](https://github.com/sct/overseerr/issues/804)) ([95c2a21](https://github.com/sct/overseerr/commit/95c2a2169799d96413b47ab24506b330435643eb))
|
||||
* **ui:** dont show bulk edit options on user list if there is only one user ([b658ddf](https://github.com/sct/overseerr/commit/b658ddf5cf61b2bb9b93cb1a4ca716cd75e18bb4))
|
||||
* **ui:** Dynamically generate path to config in warning message ([#851](https://github.com/sct/overseerr/issues/851)) ([b531a64](https://github.com/sct/overseerr/commit/b531a642f601f4ef9bf39c2f5915402157e55372))
|
||||
* **ui:** fix tables extending outside viewport in mobile formats ([e270999](https://github.com/sct/overseerr/commit/e270999745f97c2860f6a5b84e897dc6da8d6001))
|
||||
* **ui:** Hide 'Mark 4k as Available' button if 4k not enabled ([#833](https://github.com/sct/overseerr/issues/833)) ([e4a50c3](https://github.com/sct/overseerr/commit/e4a50c33f105b440243885d72a9e96595a525447))
|
||||
* **ui:** Limit max width of forms & lists ([#845](https://github.com/sct/overseerr/issues/845)) ([b9d14a9](https://github.com/sct/overseerr/commit/b9d14a9fd0f3c94d8267755147a87fe3b77fa2c3))
|
||||
* **ui:** prevent names from getting squished in AdvancedRequester user selector ([06e9411](https://github.com/sct/overseerr/commit/06e941171a1d019fbb178624167c026f6df5271c))
|
||||
* **ui:** remove yup validation from display name on user edit page ([63d7e2b](https://github.com/sct/overseerr/commit/63d7e2b39858fcb1cc0819a680eebccded7f4451))
|
||||
* **ui:** Restore original port input size ([#814](https://github.com/sct/overseerr/issues/814)) ([1ccafc0](https://github.com/sct/overseerr/commit/1ccafc0ebd368d798f9571b83910336efa317e37))
|
||||
* **ui:** show request as option even if there are no radarr/sonarr servers ([b116281](https://github.com/sct/overseerr/commit/b116281196c264b4ec35b07f1b4ffa717e50ade5))
|
||||
* **ui:** uniform-size checkboxes, vertically-aligned form labels, and fixes for other UI imperfections/inconsistencies ([#737](https://github.com/sct/overseerr/issues/737)) ([e34fbf7](https://github.com/sct/overseerr/commit/e34fbf72fda34d69b9f25563fa81f88b3c20912a))
|
||||
* **ui:** Use minimum char validation message ([#850](https://github.com/sct/overseerr/issues/850)) ([7456bea](https://github.com/sct/overseerr/commit/7456bea2ae600a28cb933278ffb310b63a474d6a))
|
||||
* **ui:** validate application url and service external urls ([026795d](https://github.com/sct/overseerr/commit/026795d4c940cb4797d3e68089456a4c3defbb21))
|
||||
* **ui:** when PersonCard has no profilePath, correctly position name/role content ([3ffd5ab](https://github.com/sct/overseerr/commit/3ffd5ab0ee8ffa63199d1428e37206f9b59fb7a5))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **cache:** add cache table and flush cache option to settings ([996bd9f](https://github.com/sct/overseerr/commit/996bd9f14ed0f56767892c169b071be4f0f628d0))
|
||||
* **cache:** external API cache ([#786](https://github.com/sct/overseerr/issues/786)) ([20289b5](https://github.com/sct/overseerr/commit/20289b5960a93545cdff9331a1a7b613f382e702))
|
||||
* **docker:** Check for /app/config volume mount during setup ([#826](https://github.com/sct/overseerr/issues/826)) ([1e5f88f](https://github.com/sct/overseerr/commit/1e5f88f462b0c69db5f6ab8e0249a5905bc6952a))
|
||||
* **frontend:** add TheTVDB external link ([#800](https://github.com/sct/overseerr/issues/800)) ([72cffd7](https://github.com/sct/overseerr/commit/72cffd74a75984ba98c456c0ec006ec378a8dcec))
|
||||
* **lang:** add support for Hungarian language ([cfacb15](https://github.com/sct/overseerr/commit/cfacb151b52d08e19d2fcd603fb4bbcd78707cdf))
|
||||
* **lang:** translations update from Weblate ([#791](https://github.com/sct/overseerr/issues/791)) ([42295e0](https://github.com/sct/overseerr/commit/42295e076a7579b226d57407a20cb0ba044e9ec1))
|
||||
* **lang:** translations update from Weblate ([#819](https://github.com/sct/overseerr/issues/819)) ([9e5e4c2](https://github.com/sct/overseerr/commit/9e5e4c22f5b25df96f47875d599ed8685791382a))
|
||||
* **lang:** translations update from Weblate ([#841](https://github.com/sct/overseerr/issues/841)) ([e4f9b8a](https://github.com/sct/overseerr/commit/e4f9b8a9848f3af00e86fc7108c823ed0584609f))
|
||||
* **lang:** translations update from Weblate ([#852](https://github.com/sct/overseerr/issues/852)) ([c5be00e](https://github.com/sct/overseerr/commit/c5be00eebfd2b0e65295edbe282cbba22fffa660))
|
||||
* **ui:** Add local login setting ([#817](https://github.com/sct/overseerr/issues/817)) ([9d0d5b8](https://github.com/sct/overseerr/commit/9d0d5b86aae025e4647bb664c6412d42192e2fe7))
|
||||
* **ui:** added next airing date to TV Shows ([#842](https://github.com/sct/overseerr/issues/842)) ([4eae02a](https://github.com/sct/overseerr/commit/4eae02a7e14e377fd69ddd4a43774cb7e3d1855b))
|
||||
* new permission to allow users to see other users requests ([033ba9d](https://github.com/sct/overseerr/commit/033ba9d41bddf6dc1c4512d8404f747e57923bca)), closes [#840](https://github.com/sct/overseerr/issues/840)
|
||||
* request as another user ([59150f9](https://github.com/sct/overseerr/commit/59150f955f7003672ef19eb9d37156e93b79c97d))
|
||||
* **tv:** show cast for the entire show instead of only the last season ([#778](https://github.com/sct/overseerr/issues/778)) ([b239598](https://github.com/sct/overseerr/commit/b239598e64d33b78dc5d7972878840149aff360a)), closes [#775](https://github.com/sct/overseerr/issues/775)
|
||||
* **ui:** Add custom title functionality ([#825](https://github.com/sct/overseerr/issues/825)) ([35c6bfc](https://github.com/sct/overseerr/commit/35c6bfc0216bf879353b3ee546b439a06c8e6121))
|
||||
|
||||
# [1.18.0](https://github.com/sct/overseerr/compare/v1.17.2...v1.18.0) (2021-01-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** prevent duplicate movie requests ([421f4c1](https://github.com/sct/overseerr/commit/421f4c17f0f206bbe7bfcbf2819014b8c7f55b6a)), closes [#705](https://github.com/sct/overseerr/issues/705)
|
||||
* **build:** fix sqlite3 build error ([#691](https://github.com/sct/overseerr/issues/691)) ([3a1f6d5](https://github.com/sct/overseerr/commit/3a1f6d5706c8fc100e88425f3d89a26a0325af79))
|
||||
* **frontend:** add poster not found image to request card and request list item ([ae9a1b3](https://github.com/sct/overseerr/commit/ae9a1b3e940ac2abf6e842d91f458daab3dd0f0d))
|
||||
* **frontend:** add poster not found image to tv details page ([0b05545](https://github.com/sct/overseerr/commit/0b055458d0ddbfd4c87ebf9b0562f161fa3445a3))
|
||||
* **frontend:** dont show external links unless slug is set ([946bd2d](https://github.com/sct/overseerr/commit/946bd2db5ecde0748b2e9bc5edfe7ca6000ec3d5))
|
||||
* **frontend:** fix server name position on plex settings page ([86efcd8](https://github.com/sct/overseerr/commit/86efcd82c34ad6490f2899ebf6f84cdd1bffc498))
|
||||
* **frontend:** fixed mismatched rounded sizing on new login ([5e352c2](https://github.com/sct/overseerr/commit/5e352c201fc2f731ca5f713ecb6901527ef354da)), closes [#721](https://github.com/sct/overseerr/issues/721)
|
||||
* **ip logging:** add env var for proxy to fix ip logging on failed logins ([#756](https://github.com/sct/overseerr/issues/756)) ([9342a40](https://github.com/sct/overseerr/commit/9342a40bbc03f7fdda23e3876b3a4a81ea8532c0))
|
||||
* **lang:** add missing i18n strings for notification settings ([2f75c4c](https://github.com/sct/overseerr/commit/2f75c4c6aed42a15bb47d3652272de8f852ec79f))
|
||||
* **notifications:** only send a single notification when standard media becomes available ([b5fd1d5](https://github.com/sct/overseerr/commit/b5fd1d520cd2a7be6e6356a25129e93af1caf542)), closes [#770](https://github.com/sct/overseerr/issues/770)
|
||||
* **permissions:** use default user permissions when creating a local user ([#713](https://github.com/sct/overseerr/issues/713)) ([660ada0](https://github.com/sct/overseerr/commit/660ada0b2025eb2c06d9054fd0a7b5a632af6af2))
|
||||
* **radarr:** fix request bug which made it unable to be added to radarr ([#760](https://github.com/sct/overseerr/issues/760)) ([45a2779](https://github.com/sct/overseerr/commit/45a277964b0c39346d7216873812e0ebe505cb79))
|
||||
* **radarr:** return the updated data when updating radarr request ([#765](https://github.com/sct/overseerr/issues/765)) ([0c6d478](https://github.com/sct/overseerr/commit/0c6d4780c355ffe1a951268fb6949491d435bbf1))
|
||||
* **requests:** handle when tvdbid is null ([#657](https://github.com/sct/overseerr/issues/657)) ([2da0da8](https://github.com/sct/overseerr/commit/2da0da826ae1d73467bc8a671fda7cc5ca1f14c9))
|
||||
* **sonarr-sync:** correctly set series with no seasons to requested status ([3812989](https://github.com/sct/overseerr/commit/3812989a1ce1e07d4af09149008043a6e2e94060)), closes [#762](https://github.com/sct/overseerr/issues/762)
|
||||
* **sync:** do not update series status if already available and no new seasons ([136d874](https://github.com/sct/overseerr/commit/136d874cba37babf9c0670844b002871710e6d99)), closes [#777](https://github.com/sct/overseerr/issues/777)
|
||||
* **ui:** Capitalization, punctuation, and grammar inconsistences & errors ([#731](https://github.com/sct/overseerr/issues/731)) ([f05d4a0](https://github.com/sct/overseerr/commit/f05d4a0d0b42905fcaee49b2471bb1f4ee77fffe))
|
||||
* lookup movie by imdbid if tmdbid does not exits for plex movie agent ([#711](https://github.com/sct/overseerr/issues/711)) ([e972288](https://github.com/sct/overseerr/commit/e97228899a5936b2525c8060abfa14b5ce31658d))
|
||||
* show recently added series even if they are not complete ([d0c830e](https://github.com/sct/overseerr/commit/d0c830e80d389f9e0f48a9b83659331f54630d03))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **lang:** translated using Weblate (Dutch) ([059995e](https://github.com/sct/overseerr/commit/059995e0ef3370a3192bd386fa6875ca0f58690a))
|
||||
* **lang:** translated using Weblate (French) ([4789583](https://github.com/sct/overseerr/commit/4789583d66305ac7b3d393659b2f3604c0acc576))
|
||||
* **lang:** translations update from Weblate ([#727](https://github.com/sct/overseerr/issues/727)) ([71875ef](https://github.com/sct/overseerr/commit/71875efb48246dbb0139ad15a4261a5661fcfe17))
|
||||
* **lang:** update languages and fix merge conflict ([083a74a](https://github.com/sct/overseerr/commit/083a74a686d202cce5775bf9752caaa9a626cf45))
|
||||
* **ui:** Move PROXY setting to UI ([#782](https://github.com/sct/overseerr/issues/782)) ([f1dd5e7](https://github.com/sct/overseerr/commit/f1dd5e7e12c1f602449c4769173dbce71e3569d0))
|
||||
* add manual availability buttons to manage slideover ([67f8aef](https://github.com/sct/overseerr/commit/67f8aef00d98c834b60cb6152ccd5cb7b5709d12)), closes [#672](https://github.com/sct/overseerr/issues/672)
|
||||
* **media:** add link to the item on plex ([#735](https://github.com/sct/overseerr/issues/735)) ([1d7150c](https://github.com/sct/overseerr/commit/1d7150c24ec5ad347093889bfceab61b664900d5))
|
||||
* Radarr & Sonarr Sync ([#734](https://github.com/sct/overseerr/issues/734)) ([ec5fb83](https://github.com/sct/overseerr/commit/ec5fb836785855eb4846fd33b49faeb94c40506a))
|
||||
* **frontend:** add option to hide all available items from discovery ([#699](https://github.com/sct/overseerr/issues/699)) ([6c1742e](https://github.com/sct/overseerr/commit/6c1742e94ccfc6c13cf1d25fd9e893ee1f431aae))
|
||||
* **lang:** add support for Portuguese (Portugal) language ([e044146](https://github.com/sct/overseerr/commit/e044146aa55109a1eccfde9650b26beb0d5ec9a6))
|
||||
* **lang:** translated using Weblate (Dutch) ([6d0f7d4](https://github.com/sct/overseerr/commit/6d0f7d4b50370c420c1017f32d48313074543743))
|
||||
* **lang:** translated using Weblate (Italian) ([9aa5c12](https://github.com/sct/overseerr/commit/9aa5c121644518c1fbb308a487c26d8998bb5a36))
|
||||
* **lang:** translated using Weblate (Portuguese (Portugal)) ([f001fb3](https://github.com/sct/overseerr/commit/f001fb3b33d4fb749acb70c45b8a55a5bbef570c))
|
||||
* **lang:** translated using Weblate (Spanish) ([4f94d22](https://github.com/sct/overseerr/commit/4f94d227fc3096bcb8a1e5cf12fe9222d6c6b711))
|
||||
* **login:** add request ip to the failed request log ([#714](https://github.com/sct/overseerr/issues/714)) ([2d31ea9](https://github.com/sct/overseerr/commit/2d31ea940ac0a1a84d2150743798b41ff6490317))
|
||||
* **users:** add editable usernames ([#715](https://github.com/sct/overseerr/issues/715)) ([20ca3f2](https://github.com/sct/overseerr/commit/20ca3f2f5fcf4a9eb0d6a8be671bb4fb1f5e6178))
|
||||
* pre-populate server info from plex.tv API ([#563](https://github.com/sct/overseerr/issues/563)) ([82ac76b](https://github.com/sct/overseerr/commit/82ac76b0540ba1133cb5384744d2499c2488a4e8))
|
||||
* **auth:** Add optional CSRF protection ([#697](https://github.com/sct/overseerr/issues/697)) ([6e25891](https://github.com/sct/overseerr/commit/6e2589178b99f8f32f0ded9a7cfd9921c33e9b60))
|
||||
* ability to edit user settings in bulk ([#597](https://github.com/sct/overseerr/issues/597)) ([4b0241c](https://github.com/sct/overseerr/commit/4b0241c3b34d4229f928c21defb10a1c051264d1))
|
||||
* **lang:** translated using Weblate (English) ([9bb11af](https://github.com/sct/overseerr/commit/9bb11afc6b4a109ae1e14d41c9fe2b71f19c470a))
|
||||
* **lang:** translated using Weblate (German) ([c2a3e8e](https://github.com/sct/overseerr/commit/c2a3e8ed5243925dce991ec7995ae831702dbc7b))
|
||||
* **lang:** translated using Weblate (Portuguese (Brazil)) ([32f4916](https://github.com/sct/overseerr/commit/32f4916c4a926097f31ed472aee031536b847bb7))
|
||||
* **lang:** translated using Weblate (Portuguese (Brazil)) ([98570c9](https://github.com/sct/overseerr/commit/98570c920e4904a594bb7464161b985094958f84))
|
||||
* **notifications:** add option to send notifications for auto-approved requests ([21db367](https://github.com/sct/overseerr/commit/21db3676d1464b63384b04c0c2926cb2a6252e9b)), closes [#267](https://github.com/sct/overseerr/issues/267)
|
||||
|
||||
## [1.17.2](https://github.com/sct/overseerr/compare/v1.17.1...v1.17.2) (2021-01-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **requests:** allow declined season requests to be re-requested ([e1032ff](https://github.com/sct/overseerr/commit/e1032ff5dfac4a8c9d4da9cf2788c19822343ad9)), closes [#690](https://github.com/sct/overseerr/issues/690)
|
||||
* **requests:** update requests to approved when parent media is set as available ([78444a9](https://github.com/sct/overseerr/commit/78444a9e643829823162389dee60cca70da56bff)), closes [#688](https://github.com/sct/overseerr/issues/688)
|
||||
|
||||
## [1.17.1](https://github.com/sct/overseerr/compare/v1.17.0...v1.17.1) (2021-01-19)
|
||||
|
||||
|
||||
|
||||
101
CONTRIBUTING.md
@@ -1,78 +1,105 @@
|
||||
# Contributing to Overseerr
|
||||
|
||||
All help is welcome and greatly appreciated. If you would like to contribute to the project the steps below can get you started:
|
||||
All help is welcome and greatly appreciated! If you would like to contribute to the project, the following instructions should get you started...
|
||||
|
||||
## Development
|
||||
|
||||
### Tools Required
|
||||
|
||||
- HTML/Typescript/Javascript editor of choice. ([VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install.)
|
||||
- [NodeJS](https://nodejs.org/en/download/) (Node 12.x.x or higher)
|
||||
- HTML/Typescript/Javascript editor
|
||||
- [VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install.
|
||||
- [NodeJS](https://nodejs.org/en/download/) (Node 14.x or higher)
|
||||
- [Yarn](https://yarnpkg.com/)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
||||
```
|
||||
1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/overseerr.git
|
||||
cd overseerr/
|
||||
```
|
||||
2. Add the remote upstream.
|
||||
|
||||
```
|
||||
2. Add the remote `upstream`:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/sct/overseerr.git
|
||||
```
|
||||
|
||||
3. Create a new branch
|
||||
3. Create a new branch:
|
||||
|
||||
```
|
||||
```bash
|
||||
git checkout -b BRANCH_NAME develop
|
||||
```
|
||||
|
||||
- Its recommended to name the branch something relevant to the feature or fix you are working on.
|
||||
- An example of this would be `fix-title-cards` or `feature-new-system`.
|
||||
- Bad examples would be `patch` or `bug`.
|
||||
- It is recommended to give your branch a meaningful name, relevant to the feature or fix you are working on.
|
||||
- Good examples:
|
||||
- `docs-docker`
|
||||
- `feature-new-system`
|
||||
- `fix-title-cards`
|
||||
- Bad examples:
|
||||
- `bug`
|
||||
- `docs`
|
||||
- `feature`
|
||||
- `fix`
|
||||
- `patch`
|
||||
|
||||
4. Run development environment
|
||||
4. Run the development environment:
|
||||
|
||||
```
|
||||
```bash
|
||||
yarn
|
||||
yarn dev
|
||||
```
|
||||
|
||||
- Alternatively you can run using [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
|
||||
- Alternatively, you can use [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
|
||||
|
||||
5. Create your patch and run appropriate tests.
|
||||
5. Create your patch and test your changes.
|
||||
|
||||
6. Follow the [guidelines](#contributing-code).
|
||||
|
||||
7. Should you need to update your fork you can do so by rebasing from upstream.
|
||||
|
||||
```
|
||||
git fetch upstream
|
||||
git rebase upstream/develop
|
||||
git push origin BRANCH_NAME -f
|
||||
```
|
||||
- Be sure to follow both the [code](#contributing-code) and [UI text](#ui-text-style) guidelines.
|
||||
- Should you need to update your fork, you can do so by rebasing from `upstream`:
|
||||
```bash
|
||||
git fetch upstream
|
||||
git rebase upstream/develop
|
||||
git push origin BRANCH_NAME -f
|
||||
```
|
||||
|
||||
### Contributing Code
|
||||
|
||||
- If you are taking on an existing bug or feature ticket, please comment on the [GitHub Issue](https://github.com/sct/overseerr/issues) to avoid multiple people working on the same thing.
|
||||
- If you are taking on an existing bug or feature ticket, please comment on the [issue](https://github.com/sct/overseerr/issues) to avoid multiple people working on the same thing.
|
||||
- All commits **must** follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
- It is okay if you squash your PR down to be a single commit that fits this standard.
|
||||
- PRs with commits not following this standard will not be merged.
|
||||
- Please make meaningful commits, or squash them
|
||||
- Always rebase your commit to the latest `develop` branch. Do not merge develop into your branch.
|
||||
- It is your responsibility to keep your branch up to date. It will not be merged unless its rebased off the latest develop branch.
|
||||
- You can create a Draft pull request early to get feedback on your work.
|
||||
- Your code must be formatted correctly or the tests will fail.
|
||||
- We use Prettier to format our codebase. It should auto run with a git hook, but its recommended to have a Prettier extension installed in your editor and have it format on save.
|
||||
- If you have questions or need help, you can reach out in [GitHub Discussions](https://github.com/sct/overseerr/discussions) or in our [Discord](https://discord.gg/PkCWJSeCk7).
|
||||
- Only open pull requests to `develop`. Never `master`. Any PR's opened to master will be closed.
|
||||
- It is okay to squash your pull request down into a single commit that fits this standard.
|
||||
- Pull requests with commits not following this standard will **not** be merged.
|
||||
- Please make meaningful commits, or squash them.
|
||||
- Always rebase your commit to the latest `develop` branch. Do **not** merge `develop` into your branch.
|
||||
- It is your responsibility to keep your branch up-to-date. Your work will **not** be merged unless it is rebased off the latest `develop` branch.
|
||||
- You can create a "draft" pull request early to get feedback on your work.
|
||||
- Your code **must** be formatted correctly, or the tests will fail.
|
||||
- We use Prettier to format our code base. It should automatically run with a Git hook, but it is recommended to have the Prettier extension installed in your editor and format on save.
|
||||
- If you have questions or need help, you can reach out via [Discussions](https://github.com/sct/overseerr/discussions) or our [Discord server](https://discord.gg/PkCWJSeCk7).
|
||||
- Only open pull requests to `develop`, never `master`! Any pull requests opened to `master` will be closed.
|
||||
|
||||
### UI Text Style
|
||||
|
||||
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'.
|
||||
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.
|
||||
8. If an additional description or "tip" is required for a form field, it should be styled using the global CSS class `label-tip`.
|
||||
9. In full sentences, abbreviations like "info" or "auto" should not be used in place of full words, unless referencing the name/label of a specific setting or option which has an abbreviation in its name.
|
||||
10. Do your best to check for spelling errors and grammatical mistakes.
|
||||
11. Do not misspell "Overseerr."
|
||||
|
||||
## Translation
|
||||
|
||||
We use [Weblate](https://hosted.weblate.org/engage/overseerr/) for our translations so please feel free to contribute to localizing Overseerr!
|
||||
We use [Weblate](https://hosted.weblate.org/engage/overseerr/) for our translations, and your help with localizing Overseerr would be greatly appreciated! If your language is not listed below, please [open a feature request](https://github.com/sct/overseerr/issues/new/choose).
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/overseerr/"><img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/multi-auto.svg" alt="Translation status" /></a>
|
||||
|
||||
## Attribution
|
||||
|
||||
|
||||
42
Dockerfile
@@ -1,4 +1,7 @@
|
||||
FROM node:12.18-alpine AS BUILD_IMAGE
|
||||
FROM node:14.16-alpine AS BUILD_IMAGE
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
|
||||
|
||||
ARG COMMIT_TAG
|
||||
ENV COMMIT_TAG=${COMMIT_TAG}
|
||||
@@ -6,30 +9,35 @@ ENV COMMIT_TAG=${COMMIT_TAG}
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
RUN yarn --frozen-lockfile && \
|
||||
RUN \
|
||||
case "${TARGETPLATFORM}" in \
|
||||
'linux/arm64') apk add --no-cache python make g++ ;; \
|
||||
'linux/arm/v7') apk add --no-cache python make g++ ;; \
|
||||
esac
|
||||
|
||||
RUN yarn --frozen-lockfile --network-timeout 1000000 && \
|
||||
yarn build
|
||||
|
||||
# remove development dependencies
|
||||
RUN yarn install --production --ignore-scripts --prefer-offline
|
||||
RUN yarn cache clean
|
||||
|
||||
FROM node:12.18-alpine
|
||||
RUN rm -rf src && \
|
||||
rm -rf server
|
||||
|
||||
ARG COMMIT_TAG
|
||||
ENV COMMIT_TAG=${COMMIT_TAG}
|
||||
|
||||
RUN apk add tzdata
|
||||
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
# copy from build image
|
||||
COPY --from=BUILD_IMAGE /app/dist ./dist
|
||||
COPY --from=BUILD_IMAGE /app/.next ./.next
|
||||
COPY --from=BUILD_IMAGE /app/node_modules ./node_modules
|
||||
RUN touch config/DOCKER
|
||||
|
||||
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
||||
|
||||
CMD yarn start
|
||||
|
||||
FROM node:14.16-alpine
|
||||
|
||||
RUN apk add --no-cache tzdata tini
|
||||
|
||||
# copy from build image
|
||||
COPY --from=BUILD_IMAGE /app /app
|
||||
WORKDIR /app
|
||||
|
||||
ENTRYPOINT [ "/sbin/tini", "--" ]
|
||||
CMD [ "yarn", "start" ]
|
||||
|
||||
EXPOSE 5055
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:12.18-alpine
|
||||
FROM node:14.16-alpine
|
||||
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
82
README.md
@@ -6,56 +6,48 @@
|
||||
<img src="https://github.com/sct/overseerr/workflows/Overseerr%20CI/badge.svg" alt="Overseerr CI">
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/PkCWJSeCk7">
|
||||
<img src="https://img.shields.io/discord/783137440809746482" alt="Discord">
|
||||
</a>
|
||||
<img src="https://img.shields.io/docker/pulls/sctx/overseerr" alt="Docker pulls">
|
||||
<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://discord.gg/PkCWJSeCk7"><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>
|
||||
<img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr">
|
||||
<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-28-orange.svg"/></a>
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-40-orange.svg"/></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
</p>
|
||||
|
||||
**Overseerr** is a free and open source software application for managing requests for your media library. It integrates with your existing services such as **Sonarr**, **Radarr** and **Plex**!
|
||||
**Overseerr** is a free and open source software application for managing requests for your media library. It integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**, and **[Plex](https://www.plex.tv/)**!
|
||||
|
||||
## Current Features
|
||||
|
||||
- Full Plex integration. Login and manage user access with Plex!
|
||||
- Integrates easily with your existing services. Currently Overseerr supports Sonarr and Radarr. More to come!
|
||||
- Syncs to your Plex library to know what titles you already have.
|
||||
- Complex request system allowing users to request individual seasons or movies in a friendly, easy to use UI.
|
||||
- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests.
|
||||
- Granular permission system
|
||||
- Mobile friendly design, for when you need to approve requests on the go!
|
||||
|
||||
## In Development
|
||||
|
||||
- User profiles.
|
||||
- User settings page (to give users the ability to modify their Overseerr experience to their liking).
|
||||
- Local user system (for those who don't use Plex).
|
||||
- Full Plex integration. Authenticate and manage user access with Plex!
|
||||
- Easy integration with your existing services. Currently, Overseerr supports Sonarr and Radarr. More to come!
|
||||
- Plex library sync, to keep track of the titles which are already available.
|
||||
- Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface.
|
||||
- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests!
|
||||
- Granular permission system.
|
||||
- Support for various notification agents.
|
||||
- Mobile-friendly design, for when you need to approve requests on the go!
|
||||
|
||||
## Planned Features
|
||||
|
||||
- More notification types.
|
||||
- Additional notification types.
|
||||
- Issues system. This will allow users to report issues with content on your media server.
|
||||
- And a ton more! Check out our [issue tracker](https://github.com/sct/overseerr/issues) to see what features people have already requested.
|
||||
- And a ton more! Check out our [issue tracker](https://github.com/sct/overseerr/issues) to see the features which have already been requested.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Check out our documenation for steps on how to install and run Overseerr:
|
||||
Check out our documentation for instructions on how to install and run Overseerr:
|
||||
|
||||
https://docs.overseerr.dev/getting-started/installation
|
||||
|
||||
## Running Overseerr
|
||||
|
||||
Currently, Overseerr is only distributed through Docker images. If you have Docker, you can run Overseerr as per:
|
||||
Currently, Overseerr is primarily distributed as Docker images. If you have Docker installed, you can simply run Overseerr with:
|
||||
|
||||
```
|
||||
docker run -d \
|
||||
--name overseerr \
|
||||
-e LOG_LEVEL=info \
|
||||
-e TZ=Asia/Tokyo \
|
||||
-p 5055:5055 \
|
||||
@@ -64,9 +56,11 @@ docker run -d \
|
||||
sctx/overseerr
|
||||
```
|
||||
|
||||
After running Overseerr for the first time, configure it by visiting the web UI at http://[address]:5055 and completing the setup steps.
|
||||
After running Overseerr for the first time, configure it by visiting the web UI at http://[address]:5055 and completing the setup steps
|
||||
|
||||
⚠️ Overseerr is currently under very heavy, rapid development and things are likely to break often. We need all the help we can get to find bugs and get them fixed to hit a more stable release. If you would like to help test the bleeding edge, please use the image **sctx/overseerr:develop** instead! ⚠️
|
||||
For more information and alternative installation methods, please see the [Overseerr documentation](https://docs.overseerr.dev/getting-started/installation).
|
||||
|
||||
⚠️ Overseerr is currently under very heavy, rapid development and things are likely to break often. We need all the help we can get to find bugs and get them fixed to hit a more stable release. If you would like to help test the bleeding edge, please use the `sctx/overseerr:develop` image instead! ⚠️
|
||||
|
||||
## Preview
|
||||
|
||||
@@ -77,23 +71,25 @@ After running Overseerr for the first time, configure it by visiting the web UI
|
||||
- Check out the [Overseerr Documentation](https://docs.overseerr.dev/) before asking for help. Your question might already be in the [FAQ](https://docs.overseerr.dev/support/faq).
|
||||
- You can get support on [Discord](https://discord.gg/PkCWJSeCk7).
|
||||
- You can ask questions in the Help category of our [GitHub Discussions](https://github.com/sct/overseerr/discussions).
|
||||
- Bugs/Feature Requests can be opened via a [GitHub issue](https://github.com/sct/overseerr/issues).
|
||||
- Bug reports and feature requests can be submitted via [GitHub Issues](https://github.com/sct/overseerr/issues).
|
||||
|
||||
## API Documentation
|
||||
|
||||
Full API documentation will soon be published automatically and available outside of running the app. Currently, you can access the API docs by running Overseerr locally and visiting http://localhost:5055/api-docs
|
||||
Our documentation is built on every commit and hosted at https://api-docs.overseerr.dev
|
||||
|
||||
You can also access the API documentation from your local Overseerr install at http://localhost:5055/api-docs
|
||||
|
||||
## Community
|
||||
|
||||
You can ask questions, share ideas, and more in [GitHub Discussions](https://github.com/sct/overseerr/discussions).
|
||||
|
||||
If you would like to chat with community members you can join the [Overseerr Discord](https://discord.gg/PkCWJSeCk7).
|
||||
If you would like to chat with other members of our growing community, [join the Overseerr Discord server](https://discord.gg/PkCWJSeCk7)!
|
||||
|
||||
Our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md) applies to all Overseerr community channels.
|
||||
|
||||
## Contributing
|
||||
|
||||
You can help build Overseerr too! Check out our [Contribution Guide](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md) to get started.
|
||||
You can help improve Overseerr too! Check out our [Contribution Guide](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md) to get started.
|
||||
|
||||
## Contributors ✨
|
||||
|
||||
@@ -133,11 +129,27 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
<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></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></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></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>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -19,4 +19,6 @@ The primary motivation for starting this project was to have an incredibly perfo
|
||||
|
||||
## We need your help!
|
||||
|
||||
Overseerr is an ambitious project. We have already poured a lot of work into this, with more coming. We need your valuable feedback and help with finding bugs. Also, being that this is an open-source project, anyone is welcome to contribute. Contribution includes building features, patching bugs, or even translating the application. You can find the contribution guide on our GitHub.
|
||||
Overseerr is an ambitious project. We have already poured a lot of work into this, and have a lot more to do. We need your valuable feedback and help to find and fix bugs. Also, with Overseerr being an open-source project, anyone is welcome to contribute. Contribution includes building new features, patching bugs, translating the application, or even just writing documentation.
|
||||
|
||||
If you would like to contribute, please be sure to review our [contribution guidelines](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Table of contents
|
||||
# Table of Contents
|
||||
|
||||
- [Introduction](README.md)
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
|
||||
## Support
|
||||
|
||||
- [Frequently Asked Questions](support/faq.md)
|
||||
- [Frequently Asked Questions (FAQ)](support/faq.md)
|
||||
- [Asking for Support](support/asking-for-support.md)
|
||||
|
||||
## Extending Overseerr
|
||||
|
||||
- [Reverse Proxy Examples](extending-overseerr/reverse-proxy-examples.md)
|
||||
- [Fail2ban Filter](extending-overseerr/fail2ban.md)
|
||||
|
||||
14
docs/extending-overseerr/fail2ban.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Fail2ban Filter
|
||||
|
||||
{% hint style="warning" %}
|
||||
If you are running Overseerr behind a reverse proxy, make sure that the **Enable Proxy Support** setting is **enabled**.
|
||||
{% endhint %}
|
||||
|
||||
To use Fail2ban with Overseerr, create a new file named `overseerr.local` in your Fail2ban `filter.d` directory with the following filter definition:
|
||||
|
||||
```
|
||||
[Definition]
|
||||
failregex = .*\[info\]\[Auth\]\: 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.
|
||||
@@ -1,18 +1,14 @@
|
||||
# Reverse Proxy Examples
|
||||
|
||||
{% hint style="warning" %}
|
||||
Base URLs cannot be configured in Overseerr. With this limitation, only subdomain configurations are supported.
|
||||
Base URLs cannot be configured in Overseerr. With this limitation, only subdomain configurations are supported. However, a Nginx subfolder workaround configuration is provided below to use at your own risk.
|
||||
{% endhint %}
|
||||
|
||||
## LE/SWAG
|
||||
## SWAG
|
||||
|
||||
### Subdomain
|
||||
A sample proxy configuration is included in [SWAG (Secure Web Application Gateway)](https://github.com/linuxserver/docker-swag). However, this page is still the only source of truth, so the SWAG sample configuration is not guaranteed to be up-to-date. If you find an inconsistency, please [report it to the LinuxServer team](https://github.com/linuxserver/reverse-proxy-confs/issues/new) or [submit a pull request to update it](https://github.com/linuxserver/reverse-proxy-confs/pulls).
|
||||
|
||||
A sample is bundled in SWAG. This page is still the only source of truth, so the sample is not guaranteed to be up to date. If you catch an inconsistency, report it to the linuxserver team, or do a pull-request against the proxy-confs repository to update the sample.
|
||||
|
||||
Rename the sample file `overseerr.subdomain.conf.sample` to `overseerr.subdomain.conf` in the `proxy-confs`folder, or create `overseerr.subdomain.conf` in the same folder with the example below.
|
||||
|
||||
Example Configuration:
|
||||
To use the bundled configuration file, simply rename `overseerr.subdomain.conf.sample` in the `proxy-confs` folder to `overseerr.subdomain.conf`. Alternatively, create a new file `overseerr.subdomain.conf` in `proxy-confs` with the following configuration:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -41,11 +37,7 @@ server {
|
||||
|
||||
## Traefik \(v2\)
|
||||
|
||||
Add the labels to the Overseerr service in your `docker-compose` file. A basic example for a `docker-compose` file using Traefik can be found [here](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/).
|
||||
|
||||
### Subdomain
|
||||
|
||||
Example Configuration:
|
||||
Add the following labels to the Overseerr service in your `docker-compose.yml` file:
|
||||
|
||||
```text
|
||||
labels:
|
||||
@@ -59,33 +51,16 @@ labels:
|
||||
- "traefik.http.services.overseerr-svc.loadbalancer.server.port=5055"
|
||||
```
|
||||
|
||||
## LE/NGINX
|
||||
For more information, see the Traefik documentation for a [basic example](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/).
|
||||
|
||||
### Subdomain
|
||||
## Nginx
|
||||
|
||||
Take the configuration below and place it in `/etc/nginx/sites-available/overseerr.example.com.conf`.
|
||||
{% tabs %}
|
||||
{% tab title="Subdomain" %}
|
||||
|
||||
Create a symlink to `/etc/nginx/sites-enabled`:
|
||||
Add the following configuration to a new file `/etc/nginx/sites-available/overseerr.example.com.conf`:
|
||||
|
||||
```text
|
||||
sudo ln -s /etc/nginx/sites-available/overseerr.example.com.conf /etc/nginx/sites-enabled/overseerr.example.com.conf
|
||||
```
|
||||
|
||||
Test the configuration:
|
||||
|
||||
```text
|
||||
sudo nginx -t
|
||||
```
|
||||
|
||||
Reload your configuration for NGINX:
|
||||
|
||||
```text
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
Example Configuration:
|
||||
|
||||
```text
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name overseerr.example.com;
|
||||
@@ -115,13 +90,15 @@ server {
|
||||
# HTTP Strict Transport Security
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||
# Reduce XSS risks (Content-Security-Policy) - uncomment to use and add URLs whenever necessary
|
||||
# add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://plex.tv; style-src 'self' 'unsafe-inline' https://rsms.me/inter/inter.css; script-src 'self'; img-src 'self' data: https://plex.tv https://assets.plex.tv https://gravatar.com https://i2.wp.com https://image.tmdb.org; font-src 'self' https://rsms.me/inter/font-files/" always;
|
||||
# add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://plex.tv; style-src 'self' 'unsafe-inline' https://rsms.me/inter/inter.css; script-src 'self' 'unsafe-inline'; img-src 'self' data: https://plex.tv https://assets.plex.tv https://gravatar.com https://secure.gravatar.com https://i2.wp.com https://image.tmdb.org; font-src 'self' https://rsms.me/inter/font-files/" always;
|
||||
# Prevent some categories of XSS attacks (X-XSS-Protection)
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
# Provide clickjacking protection (X-Frame-Options)
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
# Prevent Sniff Mimetype (X-Content-Type-Options)
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
# Tell crawling bots to not index the site
|
||||
add_header X-Robots-Tag "noindex, nofollow" always;
|
||||
|
||||
access_log /var/log/nginx/overseerr.example.com-access.log;
|
||||
error_log /var/log/nginx/overseerr.example.com-error.log;
|
||||
@@ -132,3 +109,60 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
Then, create a symlink to `/etc/nginx/sites-enabled`:
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/overseerr.example.com.conf /etc/nginx/sites-enabled/overseerr.example.com.conf
|
||||
```
|
||||
{% endtab %}
|
||||
|
||||
{% tab title="Subfolder" %}
|
||||
|
||||
{% hint style="warning" %}
|
||||
Nginx subfolder reverse proxy is unsupported. The sub filters may stop working when Overseerr is updated. Use at your own risk!
|
||||
{% endhint %}
|
||||
|
||||
Add the following location block to your existing `nginx.conf` file.
|
||||
|
||||
```nginx
|
||||
location ^~ /overseerr {
|
||||
set $app 'overseerr';
|
||||
# Remove /overseerr path to pass to the app
|
||||
rewrite ^/overseerr/?(.*)$ /$1 break;
|
||||
proxy_pass http://127.0.0.1:5055; # NO TRAILING SLASH
|
||||
# Redirect location headers
|
||||
proxy_redirect ^ /$app;
|
||||
proxy_redirect /setup /$app/setup;
|
||||
proxy_redirect /login /$app/login;
|
||||
# Sub filters to replace hardcoded paths
|
||||
proxy_set_header Accept-Encoding "";
|
||||
sub_filter_once off;
|
||||
sub_filter_types *;
|
||||
sub_filter 'href="/"' 'href="/$app"';
|
||||
sub_filter 'href="/login"' 'href="/$app/login"';
|
||||
sub_filter 'href:"/"' 'href:"/$app"';
|
||||
sub_filter '/_next' '/$app/_next';
|
||||
sub_filter '/api/v1' '/$app/api/v1';
|
||||
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
|
||||
sub_filter '/images/' '/$app/images/';
|
||||
sub_filter '/android-' '/$app/android-';
|
||||
sub_filter '/apple-' '/$app/apple-';
|
||||
sub_filter '/favicon' '/$app/favicon';
|
||||
sub_filter '/logo.png' '/$app/logo.png';
|
||||
sub_filter '/site.webmanifest' '/$app/site.webmanifest';
|
||||
}
|
||||
```
|
||||
{% endtab %}
|
||||
{% endtabs %}
|
||||
|
||||
Next, test the configuration:
|
||||
|
||||
```bash
|
||||
sudo nginx -t
|
||||
```
|
||||
|
||||
Finally, reload `nginx` for the new configuration to take effect:
|
||||
|
||||
```bash
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
@@ -15,6 +15,7 @@ After running Overseerr for the first time, configure it by visiting the web UI
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name overseerr \
|
||||
-e LOG_LEVEL=info \
|
||||
-e TZ=Asia/Tokyo \
|
||||
-p 5055:5055 \
|
||||
@@ -25,10 +26,35 @@ docker run -d \
|
||||
|
||||
{% endtab %}
|
||||
|
||||
{% tab title="Compose" %}
|
||||
|
||||
**docker-compose.yml:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
overseerr:
|
||||
image: sctx/overseerr:latest
|
||||
container_name: overseerr
|
||||
environment:
|
||||
- LOG_LEVEL=info
|
||||
- TZ=Asia/Tokyo
|
||||
ports:
|
||||
- 5055:5055
|
||||
volumes:
|
||||
- /path/to/appdata/config:/app/config
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
{% endtab %}
|
||||
|
||||
{% tab title="UID/GID" %}
|
||||
|
||||
```text
|
||||
docker run -d \
|
||||
--name overseerr \
|
||||
--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ] \
|
||||
-e LOG_LEVEL=info \
|
||||
-e TZ=Asia/Tokyo \
|
||||
@@ -121,26 +147,54 @@ This version can break any moment. Be prepared to troubleshoot any issues that a
|
||||
{% tabs %}
|
||||
|
||||
{% tab title="Gentoo" %}
|
||||
Portage overlay [GitHub Repository](https://github.com/chriscpritchard/overseerr-overlay)
|
||||
Portage overlay [GitHub Repository](https://github.com/chriscpritchard/overseerr-overlay).
|
||||
|
||||
This is now included in the list of [Gentoo repositories](https://overlays.gentoo.org/), so can be easily enabled with `eselect repository`
|
||||
|
||||
Efforts will be made to keep up to date with the latest releases, however, this cannot be guaranteed.
|
||||
|
||||
To enable using eselect repository, run:
|
||||
**To enable:**
|
||||
To enable using `eselect repository`, run:
|
||||
|
||||
```bash
|
||||
eselect repository add overseerr-overlay git https://github.com/chriscpritchard/overseerr-overlay.git
|
||||
eselect repository enable overseerr-overlay
|
||||
```
|
||||
|
||||
**To install:**
|
||||
Once complete, you can just run:
|
||||
|
||||
```bash
|
||||
emerge www-apps/overseerr
|
||||
```
|
||||
|
||||
**To install the development build:**
|
||||
A live ebuild (`=www-apps/overseerr-9999`) is also available. To use this, you will need to modify accept_keywords for this package:
|
||||
|
||||
```bash
|
||||
emerge --autounmask --autounmask-write "=www-apps/overseerr-9999"
|
||||
```
|
||||
|
||||
Once installed, you will not be notified of updates, so you can update with:
|
||||
|
||||
```bash
|
||||
emerge @live-rebuild
|
||||
```
|
||||
|
||||
or use `app-portage/smart-live-rebuild`
|
||||
|
||||
{% hint style="danger" %}
|
||||
This version can break any moment. Be prepared to troubleshoot any issues that arise!
|
||||
{% endhint %}
|
||||
|
||||
{% endtab %}
|
||||
|
||||
{% tab title="Swizzin" %}
|
||||
The installation is not implemented via docker, but barebones. The latest released version of overseerr will be used.
|
||||
|
||||
{% hint style="danger" %}
|
||||
This implementation is not yet merged to master due to missing functionality. You can beta test the limited implementation or follow the status on [the pull request](https://github.com/swizzin/swizzin/pull/567).
|
||||
{% endhint %}
|
||||
|
||||
The installation is not implemented via Docker, but barebones. The latest release version of Overseerr will be used.
|
||||
Please see the [swizzin documentation](https://swizzin.ltd/applications/overseerr) for more information.
|
||||
|
||||
To install, run the following:
|
||||
|
||||
@@ -1,34 +1,33 @@
|
||||
# Asking for Support
|
||||
|
||||
## Before asking for support, make sure you try these things first
|
||||
## Before Asking for Support
|
||||
|
||||
* Make sure you have **updated** to the latest version.
|
||||
* ["Have you tried turning it off and on again?"](https://www.youtube.com/watch?v=nn2FB1P_Mn8)
|
||||
* **Analyzing** your logs, you just might find the solution yourself!
|
||||
* **Search** the [Wiki](../), [Installation Guides](../getting-started/installation.md), and [FAQs](faq.md).
|
||||
* If you have questions, feel free to ask them on [Discord](https://discord.gg/PkCWJSeCk7) \(Please review our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md)\). Please include a link to your logs. See [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) for more details.
|
||||
Before seeking help, please make sure you have tried these following first:
|
||||
|
||||
- **Update** to the latest version.
|
||||
- ["Have you tried turning it off and on again?"](https://www.youtube.com/watch?v=nn2FB1P_Mn8)
|
||||
- **Analyze** your logs, you just might find the solution yourself!
|
||||
- **Search** the [Wiki](../), [Installation Guides](../getting-started/installation.md), and [FAQs](faq.md).
|
||||
- If you have questions, feel free to ask on [Discord](https://discord.gg/PkCWJSeCk7) \(Please review our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md).\) Be sure to include a link to your logs. See [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below.
|
||||
|
||||
## What should I include when asking for support?
|
||||
|
||||
When you contact support saying something like "it doesn't work" leaves little to go on to figure out what is wrong for you. When contacting support try to include information such as the following:
|
||||
When you contact support, a vague statement like "it doesn't work" leaves little to go on to figure out what is wrong for you. When contacting support, try to include as much information as possible. Try to answer the following questions:
|
||||
|
||||
* What did you try to do? When you describe what you did to reach the state you are in we may notice something you did different from the instructions, or something that your unique setup requires in addition. Some examples of what to provide here:
|
||||
* What command did you enter?
|
||||
* What did you click on?
|
||||
* What settings did you change?
|
||||
* Provide a step-by-step list of what you tried.
|
||||
* What do you see? We cannot see your screen so some of the following is necessary for us to know what is going on:
|
||||
* Did something happen?
|
||||
* Did something not happen?
|
||||
* Are there any error messages showing?
|
||||
* Screenshots can help us see what you are seeing
|
||||
* The Overseerr logs show exactly what happened and are often critical for identifying issues \(see [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below\).
|
||||
- What did you try to do? When you describe what you did to reach the state you are in, we may notice something you did differently from the official instructions, or something required by your unique setup. The following are questions that should be answered in your request:
|
||||
- What command did you enter?
|
||||
- What did you click on?
|
||||
- What settings did you change?
|
||||
- Provide a step-by-step list of what you tried.
|
||||
- What do you see? We cannot see your screen so some of the following is necessary for us to know what is going on:
|
||||
- Did something happen?
|
||||
- Did something not happen?
|
||||
- Are there any error messages showing?
|
||||
- Provide screenshots to help us see what you are seeing.
|
||||
- Share your Overseerr logs, which show exactly what happened and are often critical for identifying issues \(see [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below\).
|
||||
|
||||
## How can I share my logs?
|
||||
|
||||
First you will need to gather your logs from the install directory.
|
||||
|
||||
1. Collect the log file from `<Overseeerr-install-directory>/logs/overseerr.log`
|
||||
2. Open the log file and **upload the text** by going to [gist.github.com](https://gist.github.com/) and creating a new secret Gist of the contents.
|
||||
3. **Share the link** with support in [Discord](https://discord.gg/PkCWJSeCk7) by copying the URL of the page.
|
||||
|
||||
1. Locate the log file at `<Overseeerr-install-directory>/logs/overseerr.log`
|
||||
2. Open the log file and **copy its contents** into a [**secret gist** on GitHub](https://gist.github.com/). If you upload your logs elsewhere, we may ask you to share them again via GitHub Gist.
|
||||
3. **Share the link/URL to your secret gist** in the [`#support` channel in our Discord server](https://discord.gg/PkCWJSeCk7).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Frequently Asked Questions
|
||||
# Frequently Asked Questions (FAQ)
|
||||
|
||||
{% hint style="info" %}
|
||||
If you can't find a solution here, please ask on [Discord](https://discord.gg/PkCWJSeCk7). Please do not post questions on the GitHub issues tracker.
|
||||
@@ -6,9 +6,9 @@ If you can't find a solution here, please ask on [Discord](https://discord.gg/Pk
|
||||
|
||||
## General
|
||||
|
||||
### I receive 409 or 400 errors when requesting a movie or tv show!
|
||||
### I receive 409 or 400 errors when requesting a movie or TV series!
|
||||
|
||||
**A:** Verify your are running radarr and sonarr v3. Overseerr was developed for v3 and is not currently backward compatible.
|
||||
**A:** Verify you are running Radarr and Sonarr v3. Overseerr was developed for v3 and is not currently backwards-compatible with previous versions.
|
||||
|
||||
### How do I keep Overseerr up-to-date?
|
||||
|
||||
@@ -24,23 +24,23 @@ The most secure method, but also the most inconvenient, is to set up a VPN tunne
|
||||
|
||||
### Overseerr is amazing! But it is not translated in my language yet! Can I help with translations?
|
||||
|
||||
**A:** You sure can! We are using Weblate for translations! Check it out [here](https://hosted.weblate.org/engage/overseerr/). If your language is not listed please open an [enhancement request in issues](https://github.com/sct/overseerr/issues/new/choose).
|
||||
**A:** You sure can! We are using [Weblate](https://hosted.weblate.org/engage/overseerr/) for translations. If your language is not listed, please [open a feature request on GitHub](https://github.com/sct/overseerr/issues/new/choose).
|
||||
|
||||
### Where can I find the changelog?
|
||||
|
||||
**A:** You can find the changelog in the **Settings -> About** page in your instance. You can also find it on github [here](https://github.com/sct/overseerr/releases).
|
||||
**A:** You can find the changelog in the **Settings → About** page in your Overseerr instance. You can also find it on [GitHub](https://github.com/sct/overseerr/releases).
|
||||
|
||||
### Can I make 4K requests?
|
||||
|
||||
**A:** 4K requests are not supported just yet but they will be supported in the future!
|
||||
**A:** Yes! When adding your 4K Sonarr/Radarr server in **Settings → Services**, tick the `4K Server` checkbox. You also need to tick the `Default Server` checkbox if it is the default server you would like to use for 4K content requests. (To enable 4K requests, there need to be default Sonarr/Radarr servers for both 4K content **and** non-4K content.)
|
||||
|
||||
### Some media is missing from Overseerr that I know is in Plex!
|
||||
|
||||
**A:** Overseerr supports the new Plex Movie, Legacy Plex Movie, TheTVDB agent, and the TMDb agent. Please verify that your library is using one of the agents previously listed. If you are changing agents, a full metadata refresh will need to be performed. Caution, this can take a long time depending on how many items you have in your movie library.
|
||||
**A:** Overseerr supports the new Plex Movie, legacy Plex Movie, TheTVDB, and TMDb agents. Please verify that your library is using one of the agents previously listed. If you are changing agents, a full metadata refresh will need to be performed. Caution, this can take a long time depending on how many items you have in your movie library.
|
||||
|
||||
**Troubleshooting Steps:**
|
||||
|
||||
Check the Overseerr logs for media items that are missing. The logs will contain an error as to why that item could not be matched. One example might be `errorMessage":"SQLITE_CONSTRAINT: NOT NULL`. This means that the TMDb ID is missing from the Plex XML for that item.
|
||||
First, check the Overseerr logs for media items that are missing. The logs will contain an error as to why that item could not be matched. One example might be `errorMessage":"SQLITE_CONSTRAINT: NOT NULL`. This means that the TMDb ID is missing from the Plex XML for that item.
|
||||
|
||||
1. Verify that you are using one of the agents mentioned above.
|
||||
2. Refresh the metadata for just that item.
|
||||
@@ -48,48 +48,50 @@ Check the Overseerr logs for media items that are missing. The logs will contain
|
||||
4. If the item is now seen by Overseerr then repeat step 2 for each missing item. If you have a large amount of items missing then a full metadata refresh is recommended for that library.
|
||||
5. Run a full scan on Overseerr after refreshing all unmatched items.
|
||||
|
||||
Perform these steps to verify the media item has a guid Overseerr can match.
|
||||
You can also perform the following to verify the media item has a GUID Overseerr can match:
|
||||
|
||||
1. Go to the media item in Plex and **"Get info"** and click on **"View XML"**.
|
||||
2. Verify that the media item has the same format of one of the examples below.
|
||||
2. Verify that the media item's GUID follows one of the below formats:
|
||||
|
||||
**Examples:**
|
||||
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"`
|
||||
|
||||
1. TMDB agent `guid="com.plexapp.agents.themoviedb://1705"`
|
||||
2. The new Plex Movie agent `<Guid id="tmdb://464052"/>`
|
||||
3. TheTVDB agent `guid="com.plexapp.agents.thetvdb://78874/1/1"`
|
||||
4. Legacy Plex Movie agent `guid="com.plexapp.agents.imdb://tt0765446"`
|
||||
### TV series requests are failing after I updated Overseerr!
|
||||
|
||||
**A:** Language profile support for Sonarr was added in [#860](https://github.com/sct/overseerr/pull/860), along with a new "Language Profile" required setting. If your TV series requests are failing, please make sure that you have a default language profile configured for each of your Sonarr servers in **Settings → Services**.
|
||||
|
||||
### Where can I find the logs?
|
||||
|
||||
**A:** The logs are located at `<Overseeerr-install-directory>/logs/overseerr.log`
|
||||
|
||||
## User Management
|
||||
## User management
|
||||
|
||||
### Why can't I see all my Plex users?
|
||||
|
||||
**A:** Navigate to your **User List** in Overseerr and click **Import Users From Plex** button. Don't forget to check the default user permissions in the **Settings -> General Settings** page beforehand.
|
||||
**A:** Navigate to your **User List** in Overseerr and click **Import Users from Plex** button. Don't forget to check the default user permissions in the **Settings → General Settings** page beforehand.
|
||||
|
||||
### Can I create local users in Overseerr?
|
||||
|
||||
**A:** Not at this time. But it is a planned feature!
|
||||
**A:** Head to the **Users** page and hit **Create Local User**. Keep in mind that local user accounts need a valid email address.
|
||||
|
||||
### Is is possible to set user roles in Overseerr?
|
||||
|
||||
**A:** Unfortunately, this is not possible yet. It is planned!
|
||||
**A:** User roles can be set for each user on the **Users** page. The list of assignable permissions is one that is still growing, so if you have any suggestions, [make a feature request](https://github.com/sct/overseerr/issues/new/choose) on GitHub.
|
||||
|
||||
## Requests
|
||||
|
||||
### I approved a requested movie and radarr didn't search for it!
|
||||
### I approved a requested movie and Radarr didn't search for it!
|
||||
|
||||
**A:** Check your minimum availability in radarr. If an added item does not meet the minimum availability, no search will be performed. Also verify that radarr did not search for it by checking the radarr logs. Lastly, verify the item was not already being monitored by radarr. Currently there is no state sync with radarr.
|
||||
**A:** Check the minimum availability setting in your Radarr server. If a movie does not meet the minimum availability requirement, no search will be performed. Also verify that Radarr did not perform a search, by checking the Radarr logs. Lastly, verify that the item was not already being monitored by Radarr prior to approving the request.
|
||||
|
||||
### Help! My request still shows "requested" even though it's in Plex!?!
|
||||
### Help! My request still shows "requested" even though it is in Plex!
|
||||
|
||||
**A:** See "[Some media is missing from Overseerr that I know is in Plex!](./faq.md#some-media-is-missing-from-overseerr-that-i-know-is-in-plex)" for troubleshooting steps.
|
||||
|
||||
## Notifications
|
||||
|
||||
### I am getting "Username and Password not accepted" when sending email notifications to gmail!
|
||||
### I am getting "Username and Password not accepted" when attempting to send email notifications via Gmail!
|
||||
|
||||
**A:** If you have 2-Step Verification enabled on your account you will need to create an app password. More details can be found [here](https://support.google.com/mail/answer/185833).
|
||||
**A:** If you have 2-Step Verification enabled on your account, you will need to create an [app password](https://support.google.com/mail/answer/185833).
|
||||
|
||||
@@ -4,25 +4,26 @@ Overseerr already supports a good number of notification agents, such as **Disco
|
||||
|
||||
## Currently Supported Notification Agents
|
||||
|
||||
- Email
|
||||
- Discord
|
||||
- Email
|
||||
- Pushbullet
|
||||
- Pushover
|
||||
- Slack
|
||||
- Telegram
|
||||
- Pushover
|
||||
- [Webhooks](./webhooks.md)
|
||||
|
||||
## Setting up Notifications
|
||||
## Setting Up Notifications
|
||||
|
||||
Configuring your notifications is _very simple_. First, you will need to visit the **Settings** page and click **Notifications** in the menu. This will present you with all of the currently available notification agents. Click on each one individually to configure them.
|
||||
|
||||
You must configure which type of notifications you want to send _per agent_. If no types are selected, you will not receive any notifications!
|
||||
You must configure which type of notifications you want to send _per agent_. If no types are selected, you will not receive notifications!
|
||||
|
||||
Some agents may have specific configuration gotchas that will be covered in each notification agents documentation page.
|
||||
Some agents may have specific configuration "gotchas" covered in their documentation pages.
|
||||
|
||||
{% hint style="danger" %}
|
||||
Currently, you will **not receive notifications** for any auto-approved requests. However, you will still receive a notification when the media becomes available.
|
||||
You will **not receive notifications** for any automatically approved requests unless the "Enable Notifications for Automatic Approvals" setting is enabled.
|
||||
{% endhint %}
|
||||
|
||||
## Requesting new agents
|
||||
## Requesting New Notification Agents
|
||||
|
||||
If we do not currently support a notification agent you would like, feel free to request it on our [GitHub Issues](https://github.com/sct/overseerr/issues). Make sure to search first to see if someone else already requested it!
|
||||
If we do not currently support a notification agent you would like, feel free to request it on [GitHub](https://github.com/sct/overseerr/issues). However, please be sure to search first and confirm that there is not already an existing request for the agent!
|
||||
|
||||
@@ -6,7 +6,7 @@ Webhooks let you post a custom JSON payload to any endpoint you like. You can al
|
||||
|
||||
The following configuration options are available:
|
||||
|
||||
### Webhook URL (Required)
|
||||
### Webhook URL (required)
|
||||
|
||||
The URL you would like to post notifications to. Your JSON will be sent as the body of the request.
|
||||
|
||||
@@ -14,43 +14,44 @@ The URL you would like to post notifications to. Your JSON will be sent as the b
|
||||
|
||||
Custom authorization header. Anything entered for this will be sent as an `Authorization` header.
|
||||
|
||||
### Custom JSON Payload (Required)
|
||||
### JSON Payload (required)
|
||||
|
||||
Design your JSON payload as you see fit. JSON is validated before you can save or test. Overseerr provides several [template variables](./webhooks.md#template-variables) for use in the payload which will be replaced with actual values when the notifications are sent.
|
||||
|
||||
You can always reset back to the default custom payload setting by clicking the `Reset to Default JSON Payload` button under the editor.
|
||||
Customize the JSON payload to suit your needs. Overseerr provides several [template variables](./webhooks.md#template-variables) for use in the payload, which will be replaced with the relevant data when the notifications are triggered.
|
||||
|
||||
## Template Variables
|
||||
|
||||
### Main
|
||||
### General
|
||||
|
||||
- `{{notification_type}}` The type of notification. (Ex. `MEDIA_PENDING` or `MEDIA_APPROVED`)
|
||||
- `{{subject}}` The notification subject message. (For request notifications, this is the media title)
|
||||
- `{{message}}` Notification message body. (For request notifications, this is the media's overview/synopsis)
|
||||
- `{{image}}` Associated image with the request. (For request notifications, this is the media's poster)
|
||||
|
||||
### Notify User
|
||||
### User
|
||||
|
||||
These variables are usually the target user of the notification.
|
||||
|
||||
- `{{notifyuser_username}}` Target user's username.
|
||||
- `{{notifyuser_email}}` Target user's email.
|
||||
- `{{notifyuser_avatar}}` Target user's avatar.
|
||||
- `{{notifyuser_settings_discordId}}` Target user's discord ID (if one is set).
|
||||
- `{{notifyuser_settings_telegramChatId}}` Target user's telegram Chat ID (if one is set).
|
||||
|
||||
### Media
|
||||
|
||||
These variables are only included in media related notifications, such as requests.
|
||||
|
||||
- `{{media_type}}` Media type. Either `movie` or `tv`.
|
||||
- `{{media_tmdbid}}` Media's TMDB ID.
|
||||
- `{{media_imdbid}}` Media's IMDB ID.
|
||||
- `{{media_tmdbid}}` Media's TMDb ID.
|
||||
- `{{media_imdbid}}` Media's IMDb ID.
|
||||
- `{{media_tvdbid}}` Media's TVDB ID.
|
||||
- `{{media_status}}` Media's availability status. (Ex. `AVAILABLE` or `PENDING`)
|
||||
- `{{media_status4k}}` Media's 4K availability status. (Ex. `AVAILABLE` or `PENDING`)
|
||||
- `{{media_status}}` Media's availability status (e.g., `AVAILABLE` or `PENDING`).
|
||||
- `{{media_status4k}}` Media's 4K availability status (e.g., `AVAILABLE` or `PENDING`).
|
||||
|
||||
### Special Key Variables
|
||||
### Special
|
||||
|
||||
These variables must be used as a key in the JSON Payload. (Ex, `"{{extra}}": []`).
|
||||
The following variables must be used as a key in the JSON payload (e.g., `"{{extra}}": []`).
|
||||
|
||||
- `{{extra}}` This will override the value of the property to be the pre-formatted "extra" array that can come along with certain notifications. Using this variable is _not required_.
|
||||
- `{{media}}` This will override the value of the property to `null` if there is no media object passed along with the notification.
|
||||
- `{{request}}` This object will be `null` if there is no relevant request object for the notification.
|
||||
- `{{media}}` This object will be `null` if there is no relevant media object for the notification.
|
||||
- `{{extra}}` This object will contain the "extra" array of additional data for certain notifications.
|
||||
|
||||
2071
overseerr-api.yml
118
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "overseerr",
|
||||
"version": "1.17.1",
|
||||
"version": "1.21.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node --files --project server/tsconfig.json server/index.ts",
|
||||
@@ -9,7 +9,7 @@
|
||||
"build": "yarn build:next && yarn build:server",
|
||||
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\"",
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault false './src/**/!(*.test).{ts,tsx}'",
|
||||
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault false \"./src/**/!(*.test).{ts,tsx}\"",
|
||||
"migration:generate": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:generate",
|
||||
"migration:create": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:create",
|
||||
"migration:run": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:run",
|
||||
@@ -17,56 +17,64 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^0.3.1",
|
||||
"@supercharge/request-ip": "^1.1.2",
|
||||
"@svgr/webpack": "^5.5.0",
|
||||
"@tanem/react-nprogress": "^3.0.57",
|
||||
"ace-builds": "^1.4.12",
|
||||
"axios": "^0.21.1",
|
||||
"bcrypt": "^5.0.0",
|
||||
"bcrypt": "^5.0.1",
|
||||
"body-parser": "^1.19.0",
|
||||
"bowser": "^2.11.0",
|
||||
"connect-typeorm": "^1.1.4",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"country-flag-icons": "^1.2.9",
|
||||
"csurf": "^1.11.0",
|
||||
"email-templates": "^8.0.3",
|
||||
"express": "^4.17.1",
|
||||
"express-openapi-validator": "^4.10.8",
|
||||
"express-openapi-validator": "^4.12.4",
|
||||
"express-session": "^1.17.1",
|
||||
"formik": "^2.2.6",
|
||||
"gravatar-url": "^3.1.0",
|
||||
"intl": "^1.2.5",
|
||||
"lodash": "^4.17.20",
|
||||
"lodash": "^4.17.21",
|
||||
"next": "10.0.3",
|
||||
"node-schedule": "^1.3.2",
|
||||
"nodemailer": "^6.4.17",
|
||||
"nookies": "^2.5.1",
|
||||
"node-cache": "^5.1.2",
|
||||
"node-schedule": "^2.0.0",
|
||||
"nodemailer": "^6.5.0",
|
||||
"nookies": "^2.5.2",
|
||||
"openpgp": "^5.0.0-1",
|
||||
"plex-api": "^5.3.1",
|
||||
"pug": "^3.0.0",
|
||||
"pug": "^3.0.2",
|
||||
"react": "17.0.1",
|
||||
"react-ace": "^9.2.1",
|
||||
"react-ace": "^9.3.0",
|
||||
"react-animate-height": "^2.0.23",
|
||||
"react-dom": "17.0.1",
|
||||
"react-intersection-observer": "^8.31.0",
|
||||
"react-intl": "^5.10.16",
|
||||
"react-intl": "^5.13.2",
|
||||
"react-markdown": "^5.0.3",
|
||||
"react-spring": "^8.0.27",
|
||||
"react-toast-notifications": "^2.4.0",
|
||||
"react-toast-notifications": "^2.4.3",
|
||||
"react-transition-group": "^4.4.1",
|
||||
"react-truncate-markup": "^5.1.0",
|
||||
"react-use-clipboard": "1.0.7",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"secure-random-password": "^0.2.2",
|
||||
"sqlite3": "^5.0.0",
|
||||
"sqlite3": "^5.0.2",
|
||||
"swagger-ui-express": "^4.1.6",
|
||||
"swr": "^0.4.0",
|
||||
"typeorm": "^0.2.30",
|
||||
"swr": "^0.5.1",
|
||||
"typeorm": "^0.2.31",
|
||||
"uuid": "^8.3.2",
|
||||
"winston": "^3.3.3",
|
||||
"winston-daily-rotate-file": "^4.5.0",
|
||||
"winston-daily-rotate-file": "^4.5.1",
|
||||
"xml2js": "^0.4.23",
|
||||
"yamljs": "^0.3.0",
|
||||
"yup": "^0.32.8"
|
||||
"yup": "^0.32.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.12.10",
|
||||
"@commitlint/cli": "^11.0.0",
|
||||
"@commitlint/config-conventional": "^11.0.0",
|
||||
"@babel/cli": "^7.13.10",
|
||||
"@commitlint/cli": "^12.0.1",
|
||||
"@commitlint/config-conventional": "^12.0.1",
|
||||
"@semantic-release/changelog": "^5.0.1",
|
||||
"@semantic-release/commit-analyzer": "^8.0.1",
|
||||
"@semantic-release/exec": "^5.0.0",
|
||||
@@ -77,50 +85,55 @@
|
||||
"@types/bcrypt": "^3.0.0",
|
||||
"@types/body-parser": "^1.19.0",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
"@types/email-templates": "^8.0.0",
|
||||
"@types/country-flag-icons": "^1.2.0",
|
||||
"@types/csurf": "^1.11.0",
|
||||
"@types/email-templates": "^8.0.2",
|
||||
"@types/express": "^4.17.11",
|
||||
"@types/express-session": "^1.17.0",
|
||||
"@types/lodash": "^4.14.167",
|
||||
"@types/node": "^14.14.21",
|
||||
"@types/express-session": "^1.17.3",
|
||||
"@types/lodash": "^4.14.168",
|
||||
"@types/node": "^14.14.34",
|
||||
"@types/node-schedule": "^1.3.1",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"@types/nodemailer": "^6.4.1",
|
||||
"@types/react": "^17.0.3",
|
||||
"@types/react-dom": "^17.0.2",
|
||||
"@types/react-toast-notifications": "^2.4.0",
|
||||
"@types/react-transition-group": "^4.4.0",
|
||||
"@types/react-transition-group": "^4.4.1",
|
||||
"@types/secure-random-password": "^0.2.0",
|
||||
"@types/swagger-ui-express": "^4.1.2",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"@types/xml2js": "^0.4.7",
|
||||
"@types/xml2js": "^0.4.8",
|
||||
"@types/yamljs": "^0.2.31",
|
||||
"@types/yup": "^0.29.11",
|
||||
"@typescript-eslint/eslint-plugin": "^4.13.0",
|
||||
"@typescript-eslint/parser": "^4.13.0",
|
||||
"autoprefixer": "^9",
|
||||
"@typescript-eslint/eslint-plugin": "^4.17.0",
|
||||
"@typescript-eslint/parser": "^4.17.0",
|
||||
"autoprefixer": "^10.2.5",
|
||||
"babel-plugin-react-intl": "^8.2.25",
|
||||
"babel-plugin-react-intl-auto": "^3.3.0",
|
||||
"commitizen": "^4.2.3",
|
||||
"copyfiles": "^2.4.1",
|
||||
"cz-conventional-changelog": "^3.3.0",
|
||||
"eslint": "^7.18.0",
|
||||
"eslint-config-prettier": "^7.1.0",
|
||||
"eslint-plugin-formatjs": "^2.10.3",
|
||||
"eslint": "^7.21.0",
|
||||
"eslint-config-prettier": "^7.2.0",
|
||||
"eslint-plugin-formatjs": "^2.12.7",
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"eslint-plugin-prettier": "^3.3.1",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"extract-react-intl-messages": "^4.1.1",
|
||||
"husky": "^4.3.8",
|
||||
"lint-staged": "^10.5.3",
|
||||
"husky": "4.3.8",
|
||||
"lint-staged": "^10.5.4",
|
||||
"nodemon": "^2.0.7",
|
||||
"postcss": "^7",
|
||||
"postcss": "^8.2.8",
|
||||
"postcss-preset-env": "^6.7.0",
|
||||
"prettier": "^2.2.1",
|
||||
"semantic-release": "^17.3.3",
|
||||
"semantic-release-docker": "^2.2.0",
|
||||
"semantic-release": "^17.4.2",
|
||||
"semantic-release-docker-buildx": "^1.0.1",
|
||||
"tailwindcss": "npm:@tailwindcss/postcss7-compat",
|
||||
"ts-node": "^9.1.1",
|
||||
"typescript": "^4.1.3"
|
||||
"typescript": "^4.2.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"sqlite3/node-gyp": "^5.1.0"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
@@ -175,13 +188,7 @@
|
||||
"message": "chore(release): ${nextRelease.version}"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/exec",
|
||||
{
|
||||
"prepareCmd": "docker build --build-arg COMMIT_TAG=$GITHUB_SHA -t sctx/overseerr ."
|
||||
}
|
||||
],
|
||||
"semantic-release-docker",
|
||||
"semantic-release-docker-buildx",
|
||||
[
|
||||
"@semantic-release/github",
|
||||
{
|
||||
@@ -195,8 +202,19 @@
|
||||
"npmPublish": false,
|
||||
"publish": [
|
||||
{
|
||||
"path": "semantic-release-docker",
|
||||
"name": "sctx/overseerr"
|
||||
"path": "semantic-release-docker-buildx",
|
||||
"buildArgs": {
|
||||
"COMMIT_TAG": "$GITHUB_SHA"
|
||||
},
|
||||
"imageNames": [
|
||||
"sctx/overseerr",
|
||||
"ghcr.io/sct/overseerr"
|
||||
],
|
||||
"platforms": [
|
||||
"linux/amd64",
|
||||
"linux/arm64",
|
||||
"linux/arm/v7"
|
||||
]
|
||||
},
|
||||
"@semantic-release/github"
|
||||
]
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.0 KiB |
BIN
public/images/overseerr_poster_not_found.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/images/overseerr_poster_not_found_logo_center.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/images/overseerr_poster_not_found_logo_top.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 10 KiB |
1
public/images/radarr_logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg version="1.1" viewBox="0 0 1000 1115.2" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xlink="http://www.w3.org/1999/xlink"><metadata><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><defs><path id="a" d="m0 0h1024v1024h-1024z"/></defs><use transform="rotate(.704 1914.4 -5491.6)" width="100%" height="100%" fill="#ffffff" fill-opacity="0" xlink:href="#a"/><g transform="matrix(1.1348 0 0 1.1348 -.0011348 -.013738)"><path d="m105.76 154.15-1.263 714.59c-60.261 6.782-105.02-23.858-104.28-84.025l-0.216-594.25c2.312-188.02 175.85-231.02 280.22-154.52l530.2 314.93c74.563 53.572 88.403 151.53 49.965 218.76-6.873-52.739-29.067-83.101-73.823-113.74l-597.52-345.84c-44.756-30.639-82.453-23.58-83.286 44.109zm-54.377 751.54c44.941 15.597 90.16 8.63 128.04-13.47l621.16-353.43c36.958 53.109 28.79 105.66-16.706 135.19l-522.65 294.46c-75.673 36.68-172.98-2.127-209.85-62.757z" fill="#fff"/><path d="m240.52 702.59 365.02-216.68-364.35-197.07z" fill="#ffc230"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 382 KiB After Width: | Height: | Size: 372 KiB |
|
Before Width: | Height: | Size: 20 KiB |
1
public/images/sonarr_logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="216.9" viewBox="0 0 216.7 216.9" width="216.7" xmlns="http://www.w3.org/2000/svg"> <path clip-rule="evenodd" d="M216.7 108.45c0 29.833-10.533 55.4-31.6 76.7-.7.833-1.483 1.6-2.35 2.3-3.466 3.4-7.133 6.484-11 9.25-18.267 13.467-39.367 20.2-63.3 20.2-23.967 0-45.033-6.733-63.2-20.2-4.8-3.4-9.3-7.25-13.5-11.55-16.367-16.266-26.417-35.167-30.15-56.7-.733-4.2-1.217-8.467-1.45-12.8-.1-2.4-.15-4.8-.15-7.2 0-2.533.05-4.95.15-7.25 0-.233.066-.467.2-.7 1.567-26.6 12.033-49.583 31.4-68.95C53.05 10.517 78.617 0 108.45 0c29.933 0 55.484 10.517 76.65 31.55 21.067 21.433 31.6 47.067 31.6 76.9z" fill="#EEE" fill-rule="evenodd"/> <path clip-rule="evenodd" d="M194.65 42.5l-22.4 22.4C159.152 77.998 158 89.4 158 109.5c0 17.934 2.852 34.352 16.2 47.7 9.746 9.746 19 18.95 19 18.95-2.5 3.067-5.2 6.067-8.1 9-.7.833-1.483 1.6-2.35 2.3-2.533 2.5-5.167 4.817-7.9 6.95l-17.55-17.55c-15.598-15.6-27.996-17.1-48.6-17.1-19.77 0-33.223 1.822-47.7 16.3-8.647 8.647-18.55 18.6-18.55 18.6-3.767-2.867-7.333-6.034-10.7-9.5-2.8-2.8-5.417-5.667-7.85-8.6 0 0 9.798-9.848 19.15-19.2 13.852-13.853 16.1-29.916 16.1-47.85 0-17.5-2.874-33.823-15.6-46.55-8.835-8.836-21.05-21-21.05-21 2.833-3.6 5.917-7.067 9.25-10.4 2.934-2.867 5.934-5.55 9-8.05L61.1 43.85C74.102 56.852 90.767 60.2 108.7 60.2c18.467 0 35.077-3.577 48.6-17.1 8.32-8.32 19.3-19.25 19.3-19.25 2.9 2.367 5.733 4.933 8.5 7.7 3.467 3.533 6.65 7.183 9.55 10.95z" fill="#3A3F51" fill-rule="evenodd"/> <g clip-rule="evenodd"> <path d="M78.7 114c-.2-1.167-.332-2.35-.4-3.55-.032-.667-.05-1.333-.05-2 0-.7.018-1.367.05-2 0-.067.018-.133.05-.2.435-7.367 3.334-13.733 8.7-19.1 5.9-5.833 12.984-8.75 21.25-8.75 8.3 0 15.384 2.917 21.25 8.75 5.834 5.934 8.75 13.033 8.75 21.3 0 8.267-2.916 15.35-8.75 21.25-.2.233-.416.45-.65.65-.966.933-1.982 1.783-3.05 2.55-5.065 3.733-10.916 5.6-17.55 5.6s-12.466-1.866-17.5-5.6c-1.332-.934-2.582-2-3.75-3.2-4.532-4.5-7.316-9.734-8.35-15.7z" fill="#0CF" fill-rule="evenodd"/> <path d="M157.8 59.75l-15 14.65M30.785 32.526L71.65 73.25m84.6 84.25l27.808 28.78m1.855-153.894L157.8 59.75m-125.45 126l27.35-27.4" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="2"/> <path d="M157.8 59.75l-16.95 17.2M58.97 60.604l17.2 17.15M59.623 158.43l16.75-17.4m61.928-1.396l18.028 17.945" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="7"/> </g></svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/logo.png
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
@@ -8,11 +8,13 @@ const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapp
|
||||
// originally at https://raw.githubusercontent.com/ScudLee/anime-lists/master/anime-list.xml
|
||||
const MAPPING_URL =
|
||||
'https://raw.githubusercontent.com/Anime-Lists/anime-lists/master/anime-list.xml';
|
||||
const LOCAL_PATH = path.join(__dirname, '../../config/anime-list.xml');
|
||||
const LOCAL_PATH = process.env.CONFIG_DIRECTORY
|
||||
? `${process.env.CONFIG_DIRECTORY}/anime-list.xml`
|
||||
: path.join(__dirname, '../../config/anime-list.xml');
|
||||
|
||||
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 {
|
||||
@@ -125,7 +127,7 @@ class AnimeListMapping {
|
||||
}
|
||||
} else {
|
||||
// some movies do not have mapping-list, so map episode 1,2,3,..to movies
|
||||
// movies must have imdbid or tmdbid
|
||||
// movies must have imdbId or tmdbId
|
||||
const hasImdb = imdbIds.length > 1 || imdbIds[0] !== undefined;
|
||||
if ((hasImdb || tmdbId) && anime.$.defaulttvdbseason === '0') {
|
||||
if (!this.specials[tvdbId]) {
|
||||
|
||||
102
server/api/externalapi.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||
import NodeCache from 'node-cache';
|
||||
|
||||
// 5 minute default TTL (in seconds)
|
||||
const DEFAULT_TTL = 300;
|
||||
|
||||
// 10 seconds default rolling buffer (in ms)
|
||||
const DEFAULT_ROLLING_BUFFER = 10000;
|
||||
|
||||
interface ExternalAPIOptions {
|
||||
nodeCache?: NodeCache;
|
||||
headers?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
class ExternalAPI {
|
||||
protected axios: AxiosInstance;
|
||||
private baseUrl: string;
|
||||
private cache?: NodeCache;
|
||||
|
||||
constructor(
|
||||
baseUrl: string,
|
||||
params: Record<string, unknown>,
|
||||
options: ExternalAPIOptions = {}
|
||||
) {
|
||||
this.axios = axios.create({
|
||||
baseURL: baseUrl,
|
||||
params,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
this.baseUrl = baseUrl;
|
||||
this.cache = options.nodeCache;
|
||||
}
|
||||
|
||||
protected async get<T>(
|
||||
endpoint: string,
|
||||
config?: AxiosRequestConfig,
|
||||
ttl?: number
|
||||
): Promise<T> {
|
||||
const cacheKey = this.serializeCacheKey(endpoint, config?.params);
|
||||
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||
if (cachedItem) {
|
||||
return cachedItem;
|
||||
}
|
||||
|
||||
const response = await this.axios.get<T>(endpoint, config);
|
||||
|
||||
if (this.cache) {
|
||||
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
protected async getRolling<T>(
|
||||
endpoint: string,
|
||||
config?: AxiosRequestConfig,
|
||||
ttl?: number
|
||||
): Promise<T> {
|
||||
const cacheKey = this.serializeCacheKey(endpoint, config?.params);
|
||||
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||
|
||||
if (cachedItem) {
|
||||
const keyTtl = this.cache?.getTtl(cacheKey) ?? 0;
|
||||
|
||||
// If the item has passed our rolling check, fetch again in background
|
||||
if (
|
||||
keyTtl - (ttl ?? DEFAULT_TTL) * 1000 <
|
||||
Date.now() - DEFAULT_ROLLING_BUFFER
|
||||
) {
|
||||
this.axios.get<T>(endpoint, config).then((response) => {
|
||||
this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
||||
});
|
||||
}
|
||||
return cachedItem;
|
||||
}
|
||||
|
||||
const response = await this.axios.get<T>(endpoint, config);
|
||||
|
||||
if (this.cache) {
|
||||
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
private serializeCacheKey(
|
||||
endpoint: string,
|
||||
params?: Record<string, unknown>
|
||||
) {
|
||||
if (!params) {
|
||||
return `${this.baseUrl}${endpoint}`;
|
||||
}
|
||||
|
||||
return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export default ExternalAPI;
|
||||
@@ -1,5 +1,5 @@
|
||||
import NodePlexAPI from 'plex-api';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import { getSettings, PlexSettings } from '../lib/settings';
|
||||
|
||||
export interface PlexLibraryItem {
|
||||
ratingKey: string;
|
||||
@@ -80,13 +80,26 @@ interface PlexMetadataResponse {
|
||||
class PlexAPI {
|
||||
private plexClient: NodePlexAPI;
|
||||
|
||||
constructor({ plexToken }: { plexToken?: string }) {
|
||||
constructor({
|
||||
plexToken,
|
||||
plexSettings,
|
||||
timeout,
|
||||
}: {
|
||||
plexToken?: string;
|
||||
plexSettings?: PlexSettings;
|
||||
timeout?: number;
|
||||
}) {
|
||||
const settings = getSettings();
|
||||
let settingsPlex: PlexSettings | undefined;
|
||||
plexSettings
|
||||
? (settingsPlex = plexSettings)
|
||||
: (settingsPlex = getSettings().plex);
|
||||
|
||||
this.plexClient = new NodePlexAPI({
|
||||
hostname: settings.plex.ip,
|
||||
port: settings.plex.port,
|
||||
https: settings.plex.useSsl,
|
||||
hostname: settingsPlex.ip,
|
||||
port: settingsPlex.port,
|
||||
https: settingsPlex.useSsl,
|
||||
timeout: timeout,
|
||||
token: plexToken,
|
||||
authenticator: {
|
||||
authenticate: (
|
||||
@@ -105,12 +118,13 @@ class PlexAPI {
|
||||
options: {
|
||||
identifier: settings.clientId,
|
||||
product: 'Overseerr',
|
||||
deviceName: 'Overseerr',
|
||||
deviceName: settings.main.applicationTitle,
|
||||
platform: 'Overseerr',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
public async getStatus() {
|
||||
return await this.plexClient.query('/');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import xml2js from 'xml2js';
|
||||
import { PlexDevice } from '../interfaces/api/plexInterfaces';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
|
||||
@@ -29,6 +30,45 @@ interface PlexUser {
|
||||
entitlements: string[];
|
||||
}
|
||||
|
||||
interface ConnectionResponse {
|
||||
$: {
|
||||
protocol: string;
|
||||
address: string;
|
||||
port: string;
|
||||
uri: string;
|
||||
local: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DeviceResponse {
|
||||
$: {
|
||||
name: string;
|
||||
product: string;
|
||||
productVersion: string;
|
||||
platform: string;
|
||||
platformVersion: string;
|
||||
device: string;
|
||||
clientIdentifier: string;
|
||||
createdAt: string;
|
||||
lastSeenAt: string;
|
||||
provides: string;
|
||||
owned: string;
|
||||
accessToken?: string;
|
||||
publicAddress?: string;
|
||||
httpsRequired?: string;
|
||||
synced?: string;
|
||||
relay?: string;
|
||||
dnsRebindingProtection?: string;
|
||||
natLoopbackSupported?: string;
|
||||
publicAddressMatches?: string;
|
||||
presence?: string;
|
||||
ownerID?: string;
|
||||
home?: string;
|
||||
sourceTitle?: string;
|
||||
};
|
||||
Connection: ConnectionResponse[];
|
||||
}
|
||||
|
||||
interface ServerResponse {
|
||||
$: {
|
||||
id: string;
|
||||
@@ -87,6 +127,62 @@ class PlexTvAPI {
|
||||
});
|
||||
}
|
||||
|
||||
public async getDevices(): Promise<PlexDevice[]> {
|
||||
try {
|
||||
const devicesResp = await this.axios.get(
|
||||
'/api/resources?includeHttps=1',
|
||||
{
|
||||
transformResponse: [],
|
||||
responseType: 'text',
|
||||
}
|
||||
);
|
||||
const parsedXml = await xml2js.parseStringPromise(
|
||||
devicesResp.data as DeviceResponse
|
||||
);
|
||||
return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({
|
||||
name: pxml.$.name,
|
||||
product: pxml.$.product,
|
||||
productVersion: pxml.$.productVersion,
|
||||
platform: pxml.$?.platform,
|
||||
platformVersion: pxml.$?.platformVersion,
|
||||
device: pxml.$?.device,
|
||||
clientIdentifier: pxml.$.clientIdentifier,
|
||||
createdAt: new Date(parseInt(pxml.$?.createdAt, 10) * 1000),
|
||||
lastSeenAt: new Date(parseInt(pxml.$?.lastSeenAt, 10) * 1000),
|
||||
provides: pxml.$.provides.split(','),
|
||||
owned: pxml.$.owned == '1' ? true : false,
|
||||
accessToken: pxml.$?.accessToken,
|
||||
publicAddress: pxml.$?.publicAddress,
|
||||
publicAddressMatches:
|
||||
pxml.$?.publicAddressMatches == '1' ? true : false,
|
||||
httpsRequired: pxml.$?.httpsRequired == '1' ? true : false,
|
||||
synced: pxml.$?.synced == '1' ? true : false,
|
||||
relay: pxml.$?.relay == '1' ? true : false,
|
||||
dnsRebindingProtection:
|
||||
pxml.$?.dnsRebindingProtection == '1' ? true : false,
|
||||
natLoopbackSupported:
|
||||
pxml.$?.natLoopbackSupported == '1' ? true : false,
|
||||
presence: pxml.$?.presence == '1' ? true : false,
|
||||
ownerID: pxml.$?.ownerID,
|
||||
home: pxml.$?.home == '1' ? true : false,
|
||||
sourceTitle: pxml.$?.sourceTitle,
|
||||
connection: pxml?.Connection?.map((conn: ConnectionResponse) => ({
|
||||
protocol: conn.$.protocol,
|
||||
address: conn.$.address,
|
||||
port: parseInt(conn.$.port, 10),
|
||||
uri: conn.$.uri,
|
||||
local: conn.$.local == '1' ? true : false,
|
||||
})),
|
||||
}));
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong getting the devices from plex.tv', {
|
||||
label: 'Plex.tv API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
throw new Error('Invalid auth token');
|
||||
}
|
||||
}
|
||||
|
||||
public async getUser(): Promise<PlexUser> {
|
||||
try {
|
||||
const account = await this.axios.get<PlexAccountResponse>(
|
||||
@@ -96,7 +192,7 @@ class PlexTvAPI {
|
||||
return account.data.user;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong getting the account from plex.tv: ${e.message}`,
|
||||
`Something went wrong while getting the account from plex.tv: ${e.message}`,
|
||||
{ label: 'Plex.tv API' }
|
||||
);
|
||||
throw new Error('Invalid auth token');
|
||||
@@ -116,7 +212,7 @@ class PlexTvAPI {
|
||||
return parsedXml;
|
||||
}
|
||||
|
||||
public async checkUserAccess(authUser: PlexUser): Promise<boolean> {
|
||||
public async checkUserAccess(userId: number): Promise<boolean> {
|
||||
const settings = getSettings();
|
||||
|
||||
try {
|
||||
@@ -128,11 +224,11 @@ class PlexTvAPI {
|
||||
|
||||
const users = friends.MediaContainer.User;
|
||||
|
||||
const user = users.find((u) => Number(u.$.id) === authUser.id);
|
||||
const user = users.find((u) => Number(u.$.id) === userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error(
|
||||
'This user does not exist on the main plex accounts shared list'
|
||||
"This user does not exist on the main Plex account's shared list"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import Axios, { AxiosInstance } from 'axios';
|
||||
import cacheManager from '../lib/cache';
|
||||
import { RadarrSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import ExternalAPI from './externalapi';
|
||||
|
||||
interface RadarrMovieOptions {
|
||||
title: string;
|
||||
@@ -13,12 +15,13 @@ interface RadarrMovieOptions {
|
||||
searchNow?: boolean;
|
||||
}
|
||||
|
||||
interface RadarrMovie {
|
||||
export interface RadarrMovie {
|
||||
id: number;
|
||||
title: string;
|
||||
isAvailable: boolean;
|
||||
monitored: boolean;
|
||||
tmdbId: number;
|
||||
imdbId: string;
|
||||
titleSlug: string;
|
||||
folderName: string;
|
||||
path: string;
|
||||
@@ -45,15 +48,49 @@ export interface RadarrProfile {
|
||||
name: string;
|
||||
}
|
||||
|
||||
class RadarrAPI {
|
||||
private axios: AxiosInstance;
|
||||
interface QueueItem {
|
||||
movieId: number;
|
||||
size: number;
|
||||
title: string;
|
||||
sizeleft: number;
|
||||
timeleft: string;
|
||||
estimatedCompletionTime: string;
|
||||
status: string;
|
||||
trackedDownloadStatus: string;
|
||||
trackedDownloadState: string;
|
||||
downloadId: string;
|
||||
protocol: string;
|
||||
downloadClient: string;
|
||||
indexer: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface QueueResponse {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
sortKey: string;
|
||||
sortDirection: string;
|
||||
totalRecords: number;
|
||||
records: QueueItem[];
|
||||
}
|
||||
|
||||
class RadarrAPI extends ExternalAPI {
|
||||
static buildRadarrUrl(radarrSettings: RadarrSettings, path?: string): string {
|
||||
return `${radarrSettings.useSsl ? 'https' : 'http'}://${
|
||||
radarrSettings.hostname
|
||||
}:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}${path}`;
|
||||
}
|
||||
|
||||
constructor({ url, apiKey }: { url: string; apiKey: string }) {
|
||||
this.axios = Axios.create({
|
||||
baseURL: url,
|
||||
params: {
|
||||
super(
|
||||
url,
|
||||
{
|
||||
apikey: apiKey,
|
||||
},
|
||||
});
|
||||
{
|
||||
nodeCache: cacheManager.getCache('radarr').data,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public getMovies = async (): Promise<RadarrMovie[]> => {
|
||||
@@ -76,8 +113,89 @@ class RadarrAPI {
|
||||
}
|
||||
};
|
||||
|
||||
public addMovie = async (options: RadarrMovieOptions): Promise<boolean> => {
|
||||
public async getMovieByTmdbId(id: number): Promise<RadarrMovie> {
|
||||
try {
|
||||
const response = await this.axios.get<RadarrMovie[]>('/movie/lookup', {
|
||||
params: {
|
||||
term: `tmdb:${id}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.data[0]) {
|
||||
throw new Error('Movie not found');
|
||||
}
|
||||
|
||||
return response.data[0];
|
||||
} catch (e) {
|
||||
logger.error('Error retrieving movie by TMDb ID', {
|
||||
label: 'Radarr API',
|
||||
message: e.message,
|
||||
});
|
||||
throw new Error('Movie not found');
|
||||
}
|
||||
}
|
||||
|
||||
public addMovie = async (
|
||||
options: RadarrMovieOptions
|
||||
): Promise<RadarrMovie> => {
|
||||
try {
|
||||
const movie = await this.getMovieByTmdbId(options.tmdbId);
|
||||
|
||||
if (movie.downloaded) {
|
||||
logger.info(
|
||||
'Title already exists and is available. Skipping add and returning success',
|
||||
{
|
||||
label: 'Radarr',
|
||||
}
|
||||
);
|
||||
return movie;
|
||||
}
|
||||
|
||||
// movie exists in radarr but is neither downloaded nor monitored
|
||||
if (movie.id && !movie.monitored) {
|
||||
const response = await this.axios.put<RadarrMovie>(`/movie`, {
|
||||
...movie,
|
||||
title: options.title,
|
||||
qualityProfileId: options.qualityProfileId,
|
||||
profileId: options.profileId,
|
||||
titleSlug: options.tmdbId.toString(),
|
||||
minimumAvailability: options.minimumAvailability,
|
||||
tmdbId: options.tmdbId,
|
||||
year: options.year,
|
||||
rootFolderPath: options.rootFolderPath,
|
||||
monitored: options.monitored,
|
||||
addOptions: {
|
||||
searchForMovie: options.searchNow,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data.monitored) {
|
||||
logger.info(
|
||||
'Found existing title in Radarr and set it to monitored. Returning success',
|
||||
{ label: 'Radarr' }
|
||||
);
|
||||
logger.debug('Radarr update details', {
|
||||
label: 'Radarr',
|
||||
movie: response.data,
|
||||
});
|
||||
return response.data;
|
||||
} else {
|
||||
logger.error('Failed to update existing movie in Radarr', {
|
||||
label: 'Radarr',
|
||||
options,
|
||||
});
|
||||
throw new Error('Failed to update existing movie in Radarr');
|
||||
}
|
||||
}
|
||||
|
||||
if (movie.id) {
|
||||
logger.info(
|
||||
'Movie is already monitored in Radarr. Skipping add and returning success',
|
||||
{ label: 'Radarr' }
|
||||
);
|
||||
return movie;
|
||||
}
|
||||
|
||||
const response = await this.axios.post<RadarrMovie>(`/movie`, {
|
||||
title: options.title,
|
||||
qualityProfileId: options.qualityProfileId,
|
||||
@@ -104,9 +222,9 @@ class RadarrAPI {
|
||||
label: 'Radarr',
|
||||
options,
|
||||
});
|
||||
return false;
|
||||
throw new Error('Failed to add movie to Radarr');
|
||||
}
|
||||
return true;
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.',
|
||||
@@ -117,18 +235,19 @@ class RadarrAPI {
|
||||
response: e?.response?.data,
|
||||
}
|
||||
);
|
||||
if (e?.response?.data?.[0]?.errorCode === 'MovieExistsValidator') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
throw new Error('Failed to add movie to Radarr');
|
||||
}
|
||||
};
|
||||
|
||||
public getProfiles = async (): Promise<RadarrProfile[]> => {
|
||||
try {
|
||||
const response = await this.axios.get<RadarrProfile[]>(`/profile`);
|
||||
const data = await this.getRolling<RadarrProfile[]>(
|
||||
`/qualityProfile`,
|
||||
undefined,
|
||||
3600
|
||||
);
|
||||
|
||||
return response.data;
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[Radarr] Failed to retrieve profiles: ${e.message}`);
|
||||
}
|
||||
@@ -136,13 +255,27 @@ class RadarrAPI {
|
||||
|
||||
public getRootFolders = async (): Promise<RadarrRootFolder[]> => {
|
||||
try {
|
||||
const response = await this.axios.get<RadarrRootFolder[]>(`/rootfolder`);
|
||||
const data = await this.getRolling<RadarrRootFolder[]>(
|
||||
`/rootfolder`,
|
||||
undefined,
|
||||
3600
|
||||
);
|
||||
|
||||
return response.data;
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[Radarr] Failed to retrieve root folders: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public getQueue = async (): Promise<QueueItem[]> => {
|
||||
try {
|
||||
const response = await this.axios.get<QueueResponse>(`/queue`);
|
||||
|
||||
return response.data.records;
|
||||
} catch (e) {
|
||||
throw new Error(`[Radarr] Failed to retrieve queue: ${e.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default RadarrAPI;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import cacheManager from '../lib/cache';
|
||||
import ExternalAPI from './externalapi';
|
||||
|
||||
interface RTMovieOldSearchResult {
|
||||
id: number;
|
||||
@@ -55,17 +56,19 @@ export interface RTRating {
|
||||
* Unfortunately, we need to do it by searching for the movie name, so it's
|
||||
* not always accurate.
|
||||
*/
|
||||
class RottenTomatoes {
|
||||
private axios: AxiosInstance;
|
||||
|
||||
class RottenTomatoes extends ExternalAPI {
|
||||
constructor() {
|
||||
this.axios = axios.create({
|
||||
baseURL: 'https://www.rottentomatoes.com/api/private',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
super(
|
||||
'https://www.rottentomatoes.com/api/private',
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
nodeCache: cacheManager.getCache('rt').data,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,33 +88,30 @@ class RottenTomatoes {
|
||||
year: number
|
||||
): Promise<RTRating | null> {
|
||||
try {
|
||||
const response = await this.axios.get<RTMovieSearchResponse>(
|
||||
'/v1.0/movies',
|
||||
{
|
||||
params: { q: name },
|
||||
}
|
||||
);
|
||||
const data = await this.get<RTMovieSearchResponse>('/v1.0/movies', {
|
||||
params: { q: name },
|
||||
});
|
||||
|
||||
// First, attempt to match exact name and year
|
||||
let movie = response.data.movies.find(
|
||||
let movie = data.movies.find(
|
||||
(movie) => movie.year === year && movie.title === name
|
||||
);
|
||||
|
||||
// If we don't find a movie, try to match partial name and year
|
||||
if (!movie) {
|
||||
movie = response.data.movies.find(
|
||||
movie = data.movies.find(
|
||||
(movie) => movie.year === year && movie.title.includes(name)
|
||||
);
|
||||
}
|
||||
|
||||
// If we still dont find a movie, try to match just on year
|
||||
if (!movie) {
|
||||
movie = response.data.movies.find((movie) => movie.year === year);
|
||||
movie = data.movies.find((movie) => movie.year === year);
|
||||
}
|
||||
|
||||
// One last try, try exact name match only
|
||||
if (!movie) {
|
||||
movie = response.data.movies.find((movie) => movie.title === name);
|
||||
movie = data.movies.find((movie) => movie.title === name);
|
||||
}
|
||||
|
||||
if (!movie) {
|
||||
@@ -139,19 +139,14 @@ class RottenTomatoes {
|
||||
year?: number
|
||||
): Promise<RTRating | null> {
|
||||
try {
|
||||
const response = await this.axios.get<RTMultiSearchResponse>(
|
||||
'/v2.0/search/',
|
||||
{
|
||||
params: { q: name, limit: 10 },
|
||||
}
|
||||
);
|
||||
const data = await this.get<RTMultiSearchResponse>('/v2.0/search/', {
|
||||
params: { q: name, limit: 10 },
|
||||
});
|
||||
|
||||
let tvshow: RTTvSearchResult | undefined = response.data.tvSeries[0];
|
||||
let tvshow: RTTvSearchResult | undefined = data.tvSeries[0];
|
||||
|
||||
if (year) {
|
||||
tvshow = response.data.tvSeries.find(
|
||||
(series) => series.startYear === year
|
||||
);
|
||||
tvshow = data.tvSeries.find((series) => series.startYear === year);
|
||||
}
|
||||
|
||||
if (!tvshow) {
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import Axios, { AxiosInstance } from 'axios';
|
||||
import cacheManager from '../lib/cache';
|
||||
import { SonarrSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import ExternalAPI from './externalapi';
|
||||
|
||||
interface SonarrSeason {
|
||||
seasonNumber: number;
|
||||
monitored: boolean;
|
||||
statistics?: {
|
||||
previousAiring?: string;
|
||||
episodeFileCount: number;
|
||||
episodeCount: number;
|
||||
totalEpisodeCount: number;
|
||||
sizeOnDisk: number;
|
||||
percentOfEpisodes: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SonarrSeries {
|
||||
@@ -55,6 +65,33 @@ export interface SonarrSeries {
|
||||
};
|
||||
}
|
||||
|
||||
interface QueueItem {
|
||||
seriesId: number;
|
||||
episodeId: number;
|
||||
size: number;
|
||||
title: string;
|
||||
sizeleft: number;
|
||||
timeleft: string;
|
||||
estimatedCompletionTime: string;
|
||||
status: string;
|
||||
trackedDownloadStatus: string;
|
||||
trackedDownloadState: string;
|
||||
downloadId: string;
|
||||
protocol: string;
|
||||
downloadClient: string;
|
||||
indexer: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface QueueResponse {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
sortKey: string;
|
||||
sortDirection: string;
|
||||
totalRecords: number;
|
||||
records: QueueItem[];
|
||||
}
|
||||
|
||||
interface SonarrProfile {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -75,6 +112,7 @@ interface AddSeriesOptions {
|
||||
tvdbid: number;
|
||||
title: string;
|
||||
profileId: number;
|
||||
languageProfileId?: number;
|
||||
seasons: number[];
|
||||
seasonFolder: boolean;
|
||||
rootFolderPath: string;
|
||||
@@ -83,15 +121,60 @@ interface AddSeriesOptions {
|
||||
searchNow?: boolean;
|
||||
}
|
||||
|
||||
class SonarrAPI {
|
||||
private axios: AxiosInstance;
|
||||
export interface LanguageProfile {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
class SonarrAPI extends ExternalAPI {
|
||||
static buildSonarrUrl(sonarrSettings: SonarrSettings, path?: string): string {
|
||||
return `${sonarrSettings.useSsl ? 'https' : 'http'}://${
|
||||
sonarrSettings.hostname
|
||||
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}${path}`;
|
||||
}
|
||||
|
||||
constructor({ url, apiKey }: { url: string; apiKey: string }) {
|
||||
this.axios = Axios.create({
|
||||
baseURL: url,
|
||||
params: {
|
||||
super(
|
||||
url,
|
||||
{
|
||||
apikey: apiKey,
|
||||
},
|
||||
});
|
||||
{
|
||||
nodeCache: cacheManager.getCache('sonarr').data,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async getSeries(): Promise<SonarrSeries[]> {
|
||||
try {
|
||||
const response = await this.axios.get<SonarrSeries[]>('/series');
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[Radarr] Failed to retrieve series: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
|
||||
try {
|
||||
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
|
||||
params: {
|
||||
term: title,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.data[0]) {
|
||||
throw new Error('No series found');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
logger.error('Error retrieving series by series title', {
|
||||
label: 'Sonarr API',
|
||||
message: e.message,
|
||||
});
|
||||
throw new Error('No series found');
|
||||
}
|
||||
}
|
||||
|
||||
public async getSeriesByTvdbId(id: number): Promise<SonarrSeries> {
|
||||
@@ -116,7 +199,7 @@ class SonarrAPI {
|
||||
}
|
||||
}
|
||||
|
||||
public async addSeries(options: AddSeriesOptions): Promise<boolean> {
|
||||
public async addSeries(options: AddSeriesOptions): Promise<SonarrSeries> {
|
||||
try {
|
||||
const series = await this.getSeriesByTvdbId(options.tvdbid);
|
||||
|
||||
@@ -124,11 +207,6 @@ class SonarrAPI {
|
||||
if (series.id) {
|
||||
series.seasons = this.buildSeasonList(options.seasons, series.seasons);
|
||||
|
||||
series.addOptions = {
|
||||
ignoreEpisodesWithFiles: true,
|
||||
searchForMissingEpisodes: options.searchNow,
|
||||
};
|
||||
|
||||
const newSeriesResponse = await this.axios.put<SonarrSeries>(
|
||||
'/series',
|
||||
series
|
||||
@@ -138,19 +216,22 @@ class SonarrAPI {
|
||||
logger.info('Sonarr accepted request. Updated existing series', {
|
||||
label: 'Sonarr',
|
||||
});
|
||||
logger.debug('Sonarr add details', {
|
||||
logger.debug('Sonarr update details', {
|
||||
label: 'Sonarr',
|
||||
movie: newSeriesResponse.data,
|
||||
});
|
||||
if (options.searchNow) {
|
||||
this.searchSeries(newSeriesResponse.data.id);
|
||||
}
|
||||
} else {
|
||||
logger.error('Failed to add movie to Sonarr', {
|
||||
logger.error('Failed to update series in Sonarr', {
|
||||
label: 'Sonarr',
|
||||
options,
|
||||
});
|
||||
return false;
|
||||
throw new Error('Failed to update series in Sonarr');
|
||||
}
|
||||
|
||||
return true;
|
||||
return newSeriesResponse.data;
|
||||
}
|
||||
|
||||
const createdSeriesResponse = await this.axios.post<SonarrSeries>(
|
||||
@@ -158,7 +239,8 @@ class SonarrAPI {
|
||||
{
|
||||
tvdbId: options.tvdbid,
|
||||
title: options.title,
|
||||
profileId: options.profileId,
|
||||
qualityProfileId: options.profileId,
|
||||
languageProfileId: options.languageProfileId,
|
||||
seasons: this.buildSeasonList(
|
||||
options.seasons,
|
||||
series.seasons.map((season) => ({
|
||||
@@ -189,28 +271,32 @@ class SonarrAPI {
|
||||
label: 'Sonarr',
|
||||
options,
|
||||
});
|
||||
return false;
|
||||
throw new Error('Failed to add series to Sonarr');
|
||||
}
|
||||
|
||||
return true;
|
||||
return createdSeriesResponse.data;
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong adding a series to Sonarr', {
|
||||
logger.error('Something went wrong while adding a series to Sonarr.', {
|
||||
label: 'Sonarr API',
|
||||
errorMessage: e.message,
|
||||
error: e,
|
||||
response: e?.response?.data,
|
||||
});
|
||||
return false;
|
||||
throw new Error('Failed to add series');
|
||||
}
|
||||
}
|
||||
|
||||
public async getProfiles(): Promise<SonarrProfile[]> {
|
||||
try {
|
||||
const response = await this.axios.get<SonarrProfile[]>('/profile');
|
||||
const data = await this.getRolling<SonarrProfile[]>(
|
||||
'/qualityProfile',
|
||||
undefined,
|
||||
3600
|
||||
);
|
||||
|
||||
return response.data;
|
||||
return data;
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong retrieving Sonarr profiles', {
|
||||
logger.error('Something went wrong while retrieving Sonarr profiles.', {
|
||||
label: 'Sonarr API',
|
||||
message: e.message,
|
||||
});
|
||||
@@ -220,15 +306,72 @@ class SonarrAPI {
|
||||
|
||||
public async getRootFolders(): Promise<SonarrRootFolder[]> {
|
||||
try {
|
||||
const response = await this.axios.get<SonarrRootFolder[]>('/rootfolder');
|
||||
const data = await this.getRolling<SonarrRootFolder[]>(
|
||||
'/rootfolder',
|
||||
undefined,
|
||||
3600
|
||||
);
|
||||
|
||||
return response.data;
|
||||
return data;
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong retrieving Sonarr root folders', {
|
||||
logger.error(
|
||||
'Something went wrong while retrieving Sonarr root folders.',
|
||||
{
|
||||
label: 'Sonarr API',
|
||||
message: e.message,
|
||||
}
|
||||
);
|
||||
|
||||
throw new Error('Failed to get root folders');
|
||||
}
|
||||
}
|
||||
|
||||
public async getLanguageProfiles(): Promise<LanguageProfile[]> {
|
||||
try {
|
||||
const data = await this.getRolling<LanguageProfile[]>(
|
||||
'/languageprofile',
|
||||
undefined,
|
||||
3600
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
'Something went wrong while retrieving Sonarr language profiles.',
|
||||
{
|
||||
label: 'Sonarr API',
|
||||
message: e.message,
|
||||
}
|
||||
);
|
||||
|
||||
throw new Error('Failed to get language profiles');
|
||||
}
|
||||
}
|
||||
|
||||
public async searchSeries(seriesId: number): Promise<void> {
|
||||
logger.info('Executing series search command', {
|
||||
label: 'Sonarr API',
|
||||
seriesId,
|
||||
});
|
||||
await this.runCommand('SeriesSearch', { seriesId });
|
||||
}
|
||||
|
||||
private async runCommand(
|
||||
commandName: string,
|
||||
options: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.axios.post(`/command`, {
|
||||
name: commandName,
|
||||
...options,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong attempting to run a Sonarr command.', {
|
||||
label: 'Sonarr API',
|
||||
message: e.message,
|
||||
});
|
||||
throw new Error('Failed to get root folders');
|
||||
|
||||
throw new Error('Failed to run Sonarr command.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +399,16 @@ class SonarrAPI {
|
||||
|
||||
return newSeasons;
|
||||
}
|
||||
|
||||
public getQueue = async (): Promise<QueueItem[]> => {
|
||||
try {
|
||||
const response = await this.axios.get<QueueResponse>(`/queue`);
|
||||
|
||||
return response.data.records;
|
||||
} catch (e) {
|
||||
throw new Error(`[Radarr] Failed to retrieve queue: ${e.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default SonarrAPI;
|
||||
|
||||
@@ -1,934 +0,0 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
export const ANIME_KEYWORD_ID = 210024;
|
||||
|
||||
interface SearchOptions {
|
||||
query: string;
|
||||
page?: number;
|
||||
includeAdult?: boolean;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
interface DiscoverMovieOptions {
|
||||
page?: number;
|
||||
includeAdult?: boolean;
|
||||
language?: string;
|
||||
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';
|
||||
}
|
||||
|
||||
interface DiscoverTvOptions {
|
||||
page?: number;
|
||||
language?: string;
|
||||
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';
|
||||
}
|
||||
|
||||
interface TmdbMediaResult {
|
||||
id: number;
|
||||
media_type: string;
|
||||
popularity: number;
|
||||
poster_path?: string;
|
||||
backdrop_path?: string;
|
||||
vote_count: number;
|
||||
vote_average: number;
|
||||
genre_ids: number[];
|
||||
overview: string;
|
||||
original_language: string;
|
||||
}
|
||||
|
||||
export interface TmdbMovieResult extends TmdbMediaResult {
|
||||
media_type: 'movie';
|
||||
title: string;
|
||||
original_title: string;
|
||||
release_date: string;
|
||||
adult: boolean;
|
||||
video: boolean;
|
||||
}
|
||||
|
||||
export interface TmdbTvResult extends TmdbMediaResult {
|
||||
media_type: 'tv';
|
||||
name: string;
|
||||
original_name: string;
|
||||
origin_country: string[];
|
||||
first_air_date: string;
|
||||
}
|
||||
|
||||
export interface TmdbPersonResult {
|
||||
id: number;
|
||||
name: string;
|
||||
popularity: number;
|
||||
profile_path?: string;
|
||||
adult: boolean;
|
||||
media_type: 'person';
|
||||
known_for: (TmdbMovieResult | TmdbTvResult)[];
|
||||
}
|
||||
|
||||
interface TmdbPaginatedResponse {
|
||||
page: number;
|
||||
total_results: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
interface TmdbSearchMultiResponse extends TmdbPaginatedResponse {
|
||||
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[];
|
||||
}
|
||||
|
||||
interface TmdbSearchMovieResponse extends TmdbPaginatedResponse {
|
||||
results: TmdbMovieResult[];
|
||||
}
|
||||
|
||||
interface TmdbSearchTvResponse extends TmdbPaginatedResponse {
|
||||
results: TmdbTvResult[];
|
||||
}
|
||||
|
||||
interface TmdbUpcomingMoviesResponse extends TmdbPaginatedResponse {
|
||||
dates: {
|
||||
maximum: string;
|
||||
minimum: string;
|
||||
};
|
||||
results: TmdbMovieResult[];
|
||||
}
|
||||
|
||||
interface TmdbExternalIdResponse {
|
||||
movie_results: TmdbMovieResult[];
|
||||
tv_results: TmdbTvResult[];
|
||||
}
|
||||
|
||||
export interface TmdbCreditCast {
|
||||
cast_id: number;
|
||||
character: string;
|
||||
credit_id: string;
|
||||
gender?: number;
|
||||
id: number;
|
||||
name: string;
|
||||
order: number;
|
||||
profile_path?: string;
|
||||
}
|
||||
|
||||
export interface TmdbCreditCrew {
|
||||
credit_id: string;
|
||||
gender?: number;
|
||||
id: number;
|
||||
name: string;
|
||||
profile_path?: string;
|
||||
job: string;
|
||||
department: string;
|
||||
}
|
||||
|
||||
export interface TmdbExternalIds {
|
||||
imdb_id?: string;
|
||||
freebase_mid?: string;
|
||||
freebase_id?: string;
|
||||
tvdb_id?: number;
|
||||
tvrage_id?: string;
|
||||
facebook_id?: string;
|
||||
instagram_id?: string;
|
||||
twitter_id?: string;
|
||||
}
|
||||
|
||||
export interface TmdbMovieDetails {
|
||||
id: number;
|
||||
imdb_id?: string;
|
||||
adult: boolean;
|
||||
backdrop_path?: string;
|
||||
poster_path?: string;
|
||||
budget: number;
|
||||
genres: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
homepage?: string;
|
||||
original_language: string;
|
||||
original_title: string;
|
||||
overview?: string;
|
||||
popularity: number;
|
||||
production_companies: {
|
||||
id: number;
|
||||
name: string;
|
||||
logo_path?: string;
|
||||
origin_country: string;
|
||||
}[];
|
||||
production_countries: {
|
||||
iso_3166_1: string;
|
||||
name: string;
|
||||
}[];
|
||||
release_date: string;
|
||||
revenue: number;
|
||||
runtime?: number;
|
||||
spoken_languages: {
|
||||
iso_639_1: string;
|
||||
name: string;
|
||||
}[];
|
||||
status: string;
|
||||
tagline?: string;
|
||||
title: string;
|
||||
video: boolean;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
credits: {
|
||||
cast: TmdbCreditCast[];
|
||||
crew: TmdbCreditCrew[];
|
||||
};
|
||||
belongs_to_collection?: {
|
||||
id: number;
|
||||
name: string;
|
||||
poster_path?: string;
|
||||
backdrop_path?: string;
|
||||
};
|
||||
external_ids: TmdbExternalIds;
|
||||
videos: TmdbVideoResult;
|
||||
}
|
||||
|
||||
export interface TmdbVideo {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
site: 'YouTube';
|
||||
size: number;
|
||||
type:
|
||||
| 'Clip'
|
||||
| 'Teaser'
|
||||
| 'Trailer'
|
||||
| 'Featurette'
|
||||
| 'Opening Credits'
|
||||
| 'Behind the Scenes'
|
||||
| 'Bloopers';
|
||||
}
|
||||
|
||||
export interface TmdbTvEpisodeResult {
|
||||
id: number;
|
||||
air_date: string;
|
||||
episode_number: number;
|
||||
name: string;
|
||||
overview: string;
|
||||
production_code: string;
|
||||
season_number: number;
|
||||
show_id: number;
|
||||
still_path: string;
|
||||
vote_average: number;
|
||||
vote_cuont: number;
|
||||
}
|
||||
|
||||
export interface TmdbTvSeasonResult {
|
||||
id: number;
|
||||
air_date: string;
|
||||
episode_count: number;
|
||||
name: string;
|
||||
overview: string;
|
||||
poster_path?: string;
|
||||
season_number: number;
|
||||
}
|
||||
|
||||
export interface TmdbTvDetails {
|
||||
id: number;
|
||||
backdrop_path?: string;
|
||||
created_by: {
|
||||
id: number;
|
||||
credit_id: string;
|
||||
name: string;
|
||||
gender: number;
|
||||
profile_path?: string;
|
||||
}[];
|
||||
episode_run_time: number[];
|
||||
first_air_date: string;
|
||||
genres: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
homepage: string;
|
||||
in_production: boolean;
|
||||
languages: string[];
|
||||
last_air_date: string;
|
||||
last_episode_to_air?: TmdbTvEpisodeResult;
|
||||
name: string;
|
||||
next_episode_to_air?: TmdbTvEpisodeResult;
|
||||
networks: {
|
||||
id: number;
|
||||
name: string;
|
||||
logo_path: string;
|
||||
origin_country: string;
|
||||
}[];
|
||||
number_of_episodes: number;
|
||||
number_of_seasons: number;
|
||||
origin_country: string[];
|
||||
original_language: string;
|
||||
original_name: string;
|
||||
overview: string;
|
||||
popularity: number;
|
||||
poster_path?: string;
|
||||
production_companies: {
|
||||
id: number;
|
||||
logo_path?: string;
|
||||
name: string;
|
||||
origin_country: string;
|
||||
}[];
|
||||
spoken_languages: {
|
||||
english_name: string;
|
||||
iso_639_1: string;
|
||||
name: string;
|
||||
}[];
|
||||
seasons: TmdbTvSeasonResult[];
|
||||
status: string;
|
||||
type: string;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
credits: {
|
||||
cast: TmdbCreditCast[];
|
||||
crew: TmdbCreditCrew[];
|
||||
};
|
||||
external_ids: TmdbExternalIds;
|
||||
keywords: {
|
||||
results: TmdbKeyword[];
|
||||
};
|
||||
videos: TmdbVideoResult;
|
||||
}
|
||||
|
||||
export interface TmdbVideoResult {
|
||||
results: TmdbVideo[];
|
||||
}
|
||||
|
||||
export interface TmdbKeyword {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TmdbPersonDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
deathday: string;
|
||||
known_for_department: string;
|
||||
also_known_as?: string[];
|
||||
gender: number;
|
||||
biography: string;
|
||||
popularity: string;
|
||||
place_of_birth?: string;
|
||||
profile_path?: string;
|
||||
adult: boolean;
|
||||
imdb_id?: string;
|
||||
homepage?: string;
|
||||
}
|
||||
|
||||
export interface TmdbPersonCredit {
|
||||
id: number;
|
||||
original_language: string;
|
||||
episode_count: number;
|
||||
overview: string;
|
||||
origin_country: string[];
|
||||
original_name: string;
|
||||
vote_count: number;
|
||||
name: string;
|
||||
media_type?: string;
|
||||
popularity: number;
|
||||
credit_id: string;
|
||||
backdrop_path?: string;
|
||||
first_air_date: string;
|
||||
vote_average: number;
|
||||
genre_ids?: number[];
|
||||
poster_path?: string;
|
||||
original_title: string;
|
||||
video?: boolean;
|
||||
title: string;
|
||||
adult: boolean;
|
||||
release_date: string;
|
||||
}
|
||||
export interface TmdbPersonCreditCast extends TmdbPersonCredit {
|
||||
character: string;
|
||||
}
|
||||
|
||||
export interface TmdbPersonCreditCrew extends TmdbPersonCredit {
|
||||
department: string;
|
||||
job: string;
|
||||
}
|
||||
|
||||
export interface TmdbPersonCombinedCredits {
|
||||
id: number;
|
||||
cast: TmdbPersonCreditCast[];
|
||||
crew: TmdbPersonCreditCrew[];
|
||||
}
|
||||
|
||||
export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult {
|
||||
episodes: TmdbTvEpisodeResult[];
|
||||
external_ids: TmdbExternalIds;
|
||||
}
|
||||
|
||||
export interface TmdbCollection {
|
||||
id: number;
|
||||
name: string;
|
||||
overview?: string;
|
||||
poster_path?: string;
|
||||
backdrop_path?: string;
|
||||
parts: TmdbMovieResult[];
|
||||
}
|
||||
|
||||
class TheMovieDb {
|
||||
private apiKey = 'db55323b8d3e4154498498a75642b381';
|
||||
private axios: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.axios = axios.create({
|
||||
baseURL: 'https://api.themoviedb.org/3',
|
||||
params: {
|
||||
api_key: this.apiKey,
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public searchMulti = async ({
|
||||
query,
|
||||
page = 1,
|
||||
includeAdult = false,
|
||||
language = 'en-US',
|
||||
}: SearchOptions): Promise<TmdbSearchMultiResponse> => {
|
||||
try {
|
||||
const response = await this.axios.get('/search/multi', {
|
||||
params: { query, page, include_adult: includeAdult, language },
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
return {
|
||||
page: 1,
|
||||
results: [],
|
||||
total_pages: 1,
|
||||
total_results: 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
public getPerson = async ({
|
||||
personId,
|
||||
language = 'en-US',
|
||||
}: {
|
||||
personId: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbPersonDetail> => {
|
||||
try {
|
||||
const response = await this.axios.get<TmdbPersonDetail>(
|
||||
`/person/${personId}`,
|
||||
{
|
||||
params: { language },
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public getPersonCombinedCredits = async ({
|
||||
personId,
|
||||
language = 'en-US',
|
||||
}: {
|
||||
personId: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbPersonCombinedCredits> => {
|
||||
try {
|
||||
const response = await this.axios.get<TmdbPersonCombinedCredits>(
|
||||
`/person/${personId}/combined_credits`,
|
||||
{
|
||||
params: { language },
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDB] Failed to fetch person combined credits: ${e.message}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
public getMovie = async ({
|
||||
movieId,
|
||||
language = 'en-US',
|
||||
}: {
|
||||
movieId: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbMovieDetails> => {
|
||||
try {
|
||||
const response = await this.axios.get<TmdbMovieDetails>(
|
||||
`/movie/${movieId}`,
|
||||
{
|
||||
params: {
|
||||
language,
|
||||
append_to_response: 'credits,external_ids,videos',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public getTvShow = async ({
|
||||
tvId,
|
||||
language = 'en-US',
|
||||
}: {
|
||||
tvId: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbTvDetails> => {
|
||||
try {
|
||||
const response = await this.axios.get<TmdbTvDetails>(`/tv/${tvId}`, {
|
||||
params: {
|
||||
language,
|
||||
append_to_response: 'credits,external_ids,keywords,videos',
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public getTvSeason = async ({
|
||||
tvId,
|
||||
seasonNumber,
|
||||
language,
|
||||
}: {
|
||||
tvId: number;
|
||||
seasonNumber: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbSeasonWithEpisodes> => {
|
||||
try {
|
||||
const response = await this.axios.get<TmdbSeasonWithEpisodes>(
|
||||
`/tv/${tvId}/season/${seasonNumber}`,
|
||||
{
|
||||
params: {
|
||||
language,
|
||||
append_to_response: 'external_ids',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public async getMovieRecommendations({
|
||||
movieId,
|
||||
page = 1,
|
||||
language = 'en-US',
|
||||
}: {
|
||||
movieId: number;
|
||||
page?: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbSearchMovieResponse> {
|
||||
try {
|
||||
const response = await this.axios.get<TmdbSearchMovieResponse>(
|
||||
`/movie/${movieId}/recommendations`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
language,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getMovieSimilar({
|
||||
movieId,
|
||||
page = 1,
|
||||
language = 'en-US',
|
||||
}: {
|
||||
movieId: number;
|
||||
page?: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbSearchMovieResponse> {
|
||||
try {
|
||||
const response = await this.axios.get<TmdbSearchMovieResponse>(
|
||||
`/movie/${movieId}/similar`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
language,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getMoviesByKeyword({
|
||||
keywordId,
|
||||
page = 1,
|
||||
language = 'en-US',
|
||||
}: {
|
||||
keywordId: number;
|
||||
page?: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbSearchMovieResponse> {
|
||||
try {
|
||||
const response = await this.axios.get<TmdbSearchMovieResponse>(
|
||||
`/keyword/${keywordId}/movies`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
language,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getTvRecommendations({
|
||||
tvId,
|
||||
page = 1,
|
||||
language = 'en-US',
|
||||
}: {
|
||||
tvId: number;
|
||||
page?: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbSearchTvResponse> {
|
||||
try {
|
||||
const response = await this.axios.get<TmdbSearchTvResponse>(
|
||||
`/tv/${tvId}/recommendations`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
language,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDB] Failed to fetch tv recommendations: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getTvSimilar({
|
||||
tvId,
|
||||
page = 1,
|
||||
language = 'en-US',
|
||||
}: {
|
||||
tvId: number;
|
||||
page?: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbSearchTvResponse> {
|
||||
try {
|
||||
const response = await this.axios.get<TmdbSearchTvResponse>(
|
||||
`/tv/${tvId}/similar`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
language,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch tv similar: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public getDiscoverMovies = async ({
|
||||
sortBy = 'popularity.desc',
|
||||
page = 1,
|
||||
includeAdult = false,
|
||||
language = 'en-US',
|
||||
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
||||
try {
|
||||
const response = await this.axios.get<TmdbSearchMovieResponse>(
|
||||
'/discover/movie',
|
||||
{
|
||||
params: {
|
||||
sort_by: sortBy,
|
||||
page,
|
||||
include_adult: includeAdult,
|
||||
language,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public getDiscoverTv = async ({
|
||||
sortBy = 'popularity.desc',
|
||||
page = 1,
|
||||
language = 'en-US',
|
||||
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
||||
try {
|
||||
const response = await this.axios.get<TmdbSearchTvResponse>(
|
||||
'/discover/tv',
|
||||
{
|
||||
params: {
|
||||
sort_by: sortBy,
|
||||
page,
|
||||
language,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch discover tv: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public getUpcomingMovies = async ({
|
||||
page = 1,
|
||||
language = 'en-US',
|
||||
}: {
|
||||
page: number;
|
||||
language: string;
|
||||
}): Promise<TmdbUpcomingMoviesResponse> => {
|
||||
try {
|
||||
const response = await this.axios.get<TmdbUpcomingMoviesResponse>(
|
||||
'/movie/upcoming',
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
language,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public getAllTrending = async ({
|
||||
page = 1,
|
||||
timeWindow = 'day',
|
||||
language = 'en-US',
|
||||
}: {
|
||||
page?: number;
|
||||
timeWindow?: 'day' | 'week';
|
||||
language?: string;
|
||||
} = {}): Promise<TmdbSearchMultiResponse> => {
|
||||
try {
|
||||
const response = await this.axios.get<TmdbSearchMultiResponse>(
|
||||
`/trending/all/${timeWindow}`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
language,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public getMovieTrending = async ({
|
||||
page = 1,
|
||||
timeWindow = 'day',
|
||||
}: {
|
||||
page?: number;
|
||||
timeWindow?: 'day' | 'week';
|
||||
} = {}): Promise<TmdbSearchMovieResponse> => {
|
||||
try {
|
||||
const response = await this.axios.get<TmdbSearchMovieResponse>(
|
||||
`/trending/movie/${timeWindow}`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public getTvTrending = async ({
|
||||
page = 1,
|
||||
timeWindow = 'day',
|
||||
}: {
|
||||
page?: number;
|
||||
timeWindow?: 'day' | 'week';
|
||||
} = {}): Promise<TmdbSearchTvResponse> => {
|
||||
try {
|
||||
const response = await this.axios.get<TmdbSearchTvResponse>(
|
||||
`/trending/tv/${timeWindow}`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public async getByExternalId({
|
||||
externalId,
|
||||
type,
|
||||
language = 'en-US',
|
||||
}:
|
||||
| {
|
||||
externalId: string;
|
||||
type: 'imdb';
|
||||
language?: string;
|
||||
}
|
||||
| {
|
||||
externalId: number;
|
||||
type: 'tvdb';
|
||||
language?: string;
|
||||
}): Promise<TmdbExternalIdResponse> {
|
||||
try {
|
||||
const response = await this.axios.get<TmdbExternalIdResponse>(
|
||||
`/find/${externalId}`,
|
||||
{
|
||||
params: {
|
||||
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
|
||||
language,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getMovieByImdbId({
|
||||
imdbId,
|
||||
language = 'en-US',
|
||||
}: {
|
||||
imdbId: string;
|
||||
language?: string;
|
||||
}): Promise<TmdbMovieDetails> {
|
||||
try {
|
||||
const extResponse = await this.getByExternalId({
|
||||
externalId: imdbId,
|
||||
type: 'imdb',
|
||||
});
|
||||
|
||||
if (extResponse.movie_results[0]) {
|
||||
const movie = await this.getMovie({
|
||||
movieId: extResponse.movie_results[0].id,
|
||||
language,
|
||||
});
|
||||
|
||||
return movie;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'[TMDB] Failed to find a title with the provided IMDB id'
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDB] Failed to get movie by external imdb ID: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getShowByTvdbId({
|
||||
tvdbId,
|
||||
language = 'en-US',
|
||||
}: {
|
||||
tvdbId: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbTvDetails> {
|
||||
try {
|
||||
const extResponse = await this.getByExternalId({
|
||||
externalId: tvdbId,
|
||||
type: 'tvdb',
|
||||
});
|
||||
|
||||
if (extResponse.tv_results[0]) {
|
||||
const tvshow = await this.getTvShow({
|
||||
tvId: extResponse.tv_results[0].id,
|
||||
language,
|
||||
});
|
||||
|
||||
return tvshow;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`[TMDB] Failed to find a tv show with the provided TVDB id: ${tvdbId}`
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDB] Failed to get tv show by external tvdb ID: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getCollection({
|
||||
collectionId,
|
||||
language = 'en-US',
|
||||
}: {
|
||||
collectionId: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbCollection> {
|
||||
try {
|
||||
const response = await this.axios.get<TmdbCollection>(
|
||||
`/collection/${collectionId}`,
|
||||
{
|
||||
params: {
|
||||
language,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TheMovieDb;
|
||||
1
server/api/themoviedb/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const ANIME_KEYWORD_ID = 210024;
|
||||
753
server/api/themoviedb/index.ts
Normal file
@@ -0,0 +1,753 @@
|
||||
import { sortBy } from 'lodash';
|
||||
import cacheManager from '../../lib/cache';
|
||||
import ExternalAPI from '../externalapi';
|
||||
import {
|
||||
TmdbCollection,
|
||||
TmdbExternalIdResponse,
|
||||
TmdbGenre,
|
||||
TmdbGenresResult,
|
||||
TmdbLanguage,
|
||||
TmdbMovieDetails,
|
||||
TmdbNetwork,
|
||||
TmdbPersonCombinedCredits,
|
||||
TmdbPersonDetail,
|
||||
TmdbRegion,
|
||||
TmdbSearchMovieResponse,
|
||||
TmdbSearchMultiResponse,
|
||||
TmdbSearchTvResponse,
|
||||
TmdbSeasonWithEpisodes,
|
||||
TmdbTvDetails,
|
||||
TmdbUpcomingMoviesResponse,
|
||||
TmdbProductionCompany,
|
||||
} from './interfaces';
|
||||
|
||||
interface SearchOptions {
|
||||
query: string;
|
||||
page?: number;
|
||||
includeAdult?: boolean;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
interface DiscoverMovieOptions {
|
||||
page?: number;
|
||||
includeAdult?: boolean;
|
||||
language?: string;
|
||||
primaryReleaseDateGte?: string;
|
||||
primaryReleaseDateLte?: 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';
|
||||
}
|
||||
|
||||
interface DiscoverTvOptions {
|
||||
page?: number;
|
||||
language?: string;
|
||||
firstAirDateGte?: string;
|
||||
firstAirDateLte?: string;
|
||||
includeEmptyReleaseDate?: boolean;
|
||||
originalLanguage?: string;
|
||||
genre?: number;
|
||||
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';
|
||||
}
|
||||
|
||||
class TheMovieDb extends ExternalAPI {
|
||||
private region?: string;
|
||||
private originalLanguage?: string;
|
||||
constructor({
|
||||
region,
|
||||
originalLanguage,
|
||||
}: { region?: string; originalLanguage?: string } = {}) {
|
||||
super(
|
||||
'https://api.themoviedb.org/3',
|
||||
{
|
||||
api_key: 'db55323b8d3e4154498498a75642b381',
|
||||
},
|
||||
{
|
||||
nodeCache: cacheManager.getCache('tmdb').data,
|
||||
}
|
||||
);
|
||||
this.region = region;
|
||||
this.originalLanguage = originalLanguage;
|
||||
}
|
||||
|
||||
public searchMulti = async ({
|
||||
query,
|
||||
page = 1,
|
||||
includeAdult = false,
|
||||
language = 'en',
|
||||
}: SearchOptions): Promise<TmdbSearchMultiResponse> => {
|
||||
try {
|
||||
const data = await this.get<TmdbSearchMultiResponse>('/search/multi', {
|
||||
params: { query, page, include_adult: includeAdult, language },
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
return {
|
||||
page: 1,
|
||||
results: [],
|
||||
total_pages: 1,
|
||||
total_results: 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
public getPerson = async ({
|
||||
personId,
|
||||
language = 'en',
|
||||
}: {
|
||||
personId: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbPersonDetail> => {
|
||||
try {
|
||||
const data = await this.get<TmdbPersonDetail>(`/person/${personId}`, {
|
||||
params: { language },
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch person details: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public getPersonCombinedCredits = async ({
|
||||
personId,
|
||||
language = 'en',
|
||||
}: {
|
||||
personId: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbPersonCombinedCredits> => {
|
||||
try {
|
||||
const data = await this.get<TmdbPersonCombinedCredits>(
|
||||
`/person/${personId}/combined_credits`,
|
||||
{
|
||||
params: { language },
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDb] Failed to fetch person combined credits: ${e.message}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
public getMovie = async ({
|
||||
movieId,
|
||||
language = 'en',
|
||||
}: {
|
||||
movieId: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbMovieDetails> => {
|
||||
try {
|
||||
const data = await this.get<TmdbMovieDetails>(
|
||||
`/movie/${movieId}`,
|
||||
{
|
||||
params: {
|
||||
language,
|
||||
append_to_response: 'credits,external_ids,videos,release_dates',
|
||||
},
|
||||
},
|
||||
43200
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch movie details: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public getTvShow = async ({
|
||||
tvId,
|
||||
language = 'en',
|
||||
}: {
|
||||
tvId: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbTvDetails> => {
|
||||
try {
|
||||
const data = await this.get<TmdbTvDetails>(
|
||||
`/tv/${tvId}`,
|
||||
{
|
||||
params: {
|
||||
language,
|
||||
append_to_response:
|
||||
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings',
|
||||
},
|
||||
},
|
||||
43200
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public getTvSeason = async ({
|
||||
tvId,
|
||||
seasonNumber,
|
||||
language,
|
||||
}: {
|
||||
tvId: number;
|
||||
seasonNumber: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbSeasonWithEpisodes> => {
|
||||
try {
|
||||
const data = await this.get<TmdbSeasonWithEpisodes>(
|
||||
`/tv/${tvId}/season/${seasonNumber}`,
|
||||
{
|
||||
params: {
|
||||
language,
|
||||
append_to_response: 'external_ids',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public async getMovieRecommendations({
|
||||
movieId,
|
||||
page = 1,
|
||||
language = 'en',
|
||||
}: {
|
||||
movieId: number;
|
||||
page?: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbSearchMovieResponse> {
|
||||
try {
|
||||
const data = await this.get<TmdbSearchMovieResponse>(
|
||||
`/movie/${movieId}/recommendations`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
language,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getMovieSimilar({
|
||||
movieId,
|
||||
page = 1,
|
||||
language = 'en',
|
||||
}: {
|
||||
movieId: number;
|
||||
page?: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbSearchMovieResponse> {
|
||||
try {
|
||||
const data = await this.get<TmdbSearchMovieResponse>(
|
||||
`/movie/${movieId}/similar`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
language,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getMoviesByKeyword({
|
||||
keywordId,
|
||||
page = 1,
|
||||
language = 'en',
|
||||
}: {
|
||||
keywordId: number;
|
||||
page?: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbSearchMovieResponse> {
|
||||
try {
|
||||
const data = await this.get<TmdbSearchMovieResponse>(
|
||||
`/keyword/${keywordId}/movies`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
language,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch movies by keyword: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getTvRecommendations({
|
||||
tvId,
|
||||
page = 1,
|
||||
language = 'en',
|
||||
}: {
|
||||
tvId: number;
|
||||
page?: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbSearchTvResponse> {
|
||||
try {
|
||||
const data = await this.get<TmdbSearchTvResponse>(
|
||||
`/tv/${tvId}/recommendations`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
language,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDb] Failed to fetch TV recommendations: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getTvSimilar({
|
||||
tvId,
|
||||
page = 1,
|
||||
language = 'en',
|
||||
}: {
|
||||
tvId: number;
|
||||
page?: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbSearchTvResponse> {
|
||||
try {
|
||||
const data = await this.get<TmdbSearchTvResponse>(`/tv/${tvId}/similar`, {
|
||||
params: {
|
||||
page,
|
||||
language,
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch TV similar: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public getDiscoverMovies = async ({
|
||||
sortBy = 'popularity.desc',
|
||||
page = 1,
|
||||
includeAdult = false,
|
||||
language = 'en',
|
||||
primaryReleaseDateGte,
|
||||
primaryReleaseDateLte,
|
||||
originalLanguage,
|
||||
genre,
|
||||
studio,
|
||||
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
||||
try {
|
||||
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
|
||||
params: {
|
||||
sort_by: sortBy,
|
||||
page,
|
||||
include_adult: includeAdult,
|
||||
language,
|
||||
region: this.region,
|
||||
with_original_language: originalLanguage ?? this.originalLanguage,
|
||||
'primary_release_date.gte': primaryReleaseDateGte,
|
||||
'primary_release_date.lte': primaryReleaseDateLte,
|
||||
with_genres: genre,
|
||||
with_companies: studio,
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public getDiscoverTv = async ({
|
||||
sortBy = 'popularity.desc',
|
||||
page = 1,
|
||||
language = 'en-US',
|
||||
firstAirDateGte,
|
||||
firstAirDateLte,
|
||||
includeEmptyReleaseDate = false,
|
||||
originalLanguage,
|
||||
genre,
|
||||
network,
|
||||
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
||||
try {
|
||||
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,
|
||||
include_null_first_air_dates: includeEmptyReleaseDate,
|
||||
with_genres: genre,
|
||||
with_networks: network,
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch discover TV: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public getUpcomingMovies = async ({
|
||||
page = 1,
|
||||
language = 'en',
|
||||
}: {
|
||||
page: number;
|
||||
language: string;
|
||||
}): Promise<TmdbUpcomingMoviesResponse> => {
|
||||
try {
|
||||
const data = await this.get<TmdbUpcomingMoviesResponse>(
|
||||
'/movie/upcoming',
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
language,
|
||||
region: this.region,
|
||||
originalLanguage: this.originalLanguage,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch upcoming movies: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public getAllTrending = async ({
|
||||
page = 1,
|
||||
timeWindow = 'day',
|
||||
language = 'en',
|
||||
}: {
|
||||
page?: number;
|
||||
timeWindow?: 'day' | 'week';
|
||||
language?: string;
|
||||
} = {}): Promise<TmdbSearchMultiResponse> => {
|
||||
try {
|
||||
const data = await this.get<TmdbSearchMultiResponse>(
|
||||
`/trending/all/${timeWindow}`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
language,
|
||||
region: this.region,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public getMovieTrending = async ({
|
||||
page = 1,
|
||||
timeWindow = 'day',
|
||||
}: {
|
||||
page?: number;
|
||||
timeWindow?: 'day' | 'week';
|
||||
} = {}): Promise<TmdbSearchMovieResponse> => {
|
||||
try {
|
||||
const data = await this.get<TmdbSearchMovieResponse>(
|
||||
`/trending/movie/${timeWindow}`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public getTvTrending = async ({
|
||||
page = 1,
|
||||
timeWindow = 'day',
|
||||
}: {
|
||||
page?: number;
|
||||
timeWindow?: 'day' | 'week';
|
||||
} = {}): Promise<TmdbSearchTvResponse> => {
|
||||
try {
|
||||
const data = await this.get<TmdbSearchTvResponse>(
|
||||
`/trending/tv/${timeWindow}`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public async getByExternalId({
|
||||
externalId,
|
||||
type,
|
||||
language = 'en',
|
||||
}:
|
||||
| {
|
||||
externalId: string;
|
||||
type: 'imdb';
|
||||
language?: string;
|
||||
}
|
||||
| {
|
||||
externalId: number;
|
||||
type: 'tvdb';
|
||||
language?: string;
|
||||
}): Promise<TmdbExternalIdResponse> {
|
||||
try {
|
||||
const data = await this.get<TmdbExternalIdResponse>(
|
||||
`/find/${externalId}`,
|
||||
{
|
||||
params: {
|
||||
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
|
||||
language,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to find by external ID: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getMovieByImdbId({
|
||||
imdbId,
|
||||
language = 'en',
|
||||
}: {
|
||||
imdbId: string;
|
||||
language?: string;
|
||||
}): Promise<TmdbMovieDetails> {
|
||||
try {
|
||||
const extResponse = await this.getByExternalId({
|
||||
externalId: imdbId,
|
||||
type: 'imdb',
|
||||
});
|
||||
|
||||
if (extResponse.movie_results[0]) {
|
||||
const movie = await this.getMovie({
|
||||
movieId: extResponse.movie_results[0].id,
|
||||
language,
|
||||
});
|
||||
|
||||
return movie;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'[TMDb] Failed to find a title with the provided IMDB id'
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDb] Failed to get movie by external imdb ID: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getShowByTvdbId({
|
||||
tvdbId,
|
||||
language = 'en',
|
||||
}: {
|
||||
tvdbId: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbTvDetails> {
|
||||
try {
|
||||
const extResponse = await this.getByExternalId({
|
||||
externalId: tvdbId,
|
||||
type: 'tvdb',
|
||||
});
|
||||
|
||||
if (extResponse.tv_results[0]) {
|
||||
const tvshow = await this.getTvShow({
|
||||
tvId: extResponse.tv_results[0].id,
|
||||
language,
|
||||
});
|
||||
|
||||
return tvshow;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`[TMDb] Failed to find a TV show with the provided TVDB ID: ${tvdbId}`
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDb] Failed to get TV show using the external TVDB ID: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getCollection({
|
||||
collectionId,
|
||||
language = 'en',
|
||||
}: {
|
||||
collectionId: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbCollection> {
|
||||
try {
|
||||
const data = await this.get<TmdbCollection>(
|
||||
`/collection/${collectionId}`,
|
||||
{
|
||||
params: {
|
||||
language,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch collection: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getRegions(): Promise<TmdbRegion[]> {
|
||||
try {
|
||||
const data = await this.get<TmdbRegion[]>(
|
||||
'/configuration/countries',
|
||||
{},
|
||||
86400 // 24 hours
|
||||
);
|
||||
|
||||
const regions = sortBy(data, 'english_name');
|
||||
|
||||
return regions;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch countries: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getLanguages(): Promise<TmdbLanguage[]> {
|
||||
try {
|
||||
const data = await this.get<TmdbLanguage[]>(
|
||||
'/configuration/languages',
|
||||
{},
|
||||
86400 // 24 hours
|
||||
);
|
||||
|
||||
const languages = sortBy(data, 'english_name');
|
||||
|
||||
return languages;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch langauges: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getStudio(studioId: number): Promise<TmdbProductionCompany> {
|
||||
try {
|
||||
const data = await this.get<TmdbProductionCompany>(
|
||||
`/company/${studioId}`
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch movie studio: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getNetwork(networkId: number): Promise<TmdbNetwork> {
|
||||
try {
|
||||
const data = await this.get<TmdbNetwork>(`/network/${networkId}`);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch TV network: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getMovieGenres({
|
||||
language = 'en',
|
||||
}: {
|
||||
language?: string;
|
||||
} = {}): Promise<TmdbGenre[]> {
|
||||
try {
|
||||
const data = await this.get<TmdbGenresResult>(
|
||||
'/genre/movie/list',
|
||||
{
|
||||
params: {
|
||||
language,
|
||||
},
|
||||
},
|
||||
86400 // 24 hours
|
||||
);
|
||||
|
||||
const movieGenres = sortBy(data.genres, 'name');
|
||||
|
||||
return movieGenres;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch movie genres: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getTvGenres({
|
||||
language = 'en',
|
||||
}: {
|
||||
language?: string;
|
||||
} = {}): Promise<TmdbGenre[]> {
|
||||
try {
|
||||
const data = await this.get<TmdbGenresResult>(
|
||||
'/genre/tv/list',
|
||||
{
|
||||
params: {
|
||||
language,
|
||||
},
|
||||
},
|
||||
86400 // 24 hours
|
||||
);
|
||||
|
||||
const tvGenres = sortBy(data.genres, 'name');
|
||||
|
||||
return tvGenres;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch TV genres: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TheMovieDb;
|
||||
402
server/api/themoviedb/interfaces.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
interface TmdbMediaResult {
|
||||
id: number;
|
||||
media_type: string;
|
||||
popularity: number;
|
||||
poster_path?: string;
|
||||
backdrop_path?: string;
|
||||
vote_count: number;
|
||||
vote_average: number;
|
||||
genre_ids: number[];
|
||||
overview: string;
|
||||
original_language: string;
|
||||
}
|
||||
|
||||
export interface TmdbMovieResult extends TmdbMediaResult {
|
||||
media_type: 'movie';
|
||||
title: string;
|
||||
original_title: string;
|
||||
release_date: string;
|
||||
adult: boolean;
|
||||
video: boolean;
|
||||
}
|
||||
|
||||
export interface TmdbTvResult extends TmdbMediaResult {
|
||||
media_type: 'tv';
|
||||
name: string;
|
||||
original_name: string;
|
||||
origin_country: string[];
|
||||
first_air_date: string;
|
||||
}
|
||||
|
||||
export interface TmdbPersonResult {
|
||||
id: number;
|
||||
name: string;
|
||||
popularity: number;
|
||||
profile_path?: string;
|
||||
adult: boolean;
|
||||
media_type: 'person';
|
||||
known_for: (TmdbMovieResult | TmdbTvResult)[];
|
||||
}
|
||||
|
||||
interface TmdbPaginatedResponse {
|
||||
page: number;
|
||||
total_results: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse {
|
||||
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[];
|
||||
}
|
||||
|
||||
export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse {
|
||||
results: TmdbMovieResult[];
|
||||
}
|
||||
|
||||
export interface TmdbSearchTvResponse extends TmdbPaginatedResponse {
|
||||
results: TmdbTvResult[];
|
||||
}
|
||||
|
||||
export interface TmdbUpcomingMoviesResponse extends TmdbPaginatedResponse {
|
||||
dates: {
|
||||
maximum: string;
|
||||
minimum: string;
|
||||
};
|
||||
results: TmdbMovieResult[];
|
||||
}
|
||||
|
||||
export interface TmdbExternalIdResponse {
|
||||
movie_results: TmdbMovieResult[];
|
||||
tv_results: TmdbTvResult[];
|
||||
}
|
||||
|
||||
export interface TmdbCreditCast {
|
||||
cast_id: number;
|
||||
character: string;
|
||||
credit_id: string;
|
||||
gender?: number;
|
||||
id: number;
|
||||
name: string;
|
||||
order: number;
|
||||
profile_path?: string;
|
||||
}
|
||||
|
||||
export interface TmdbAggregateCreditCast extends TmdbCreditCast {
|
||||
roles: {
|
||||
credit_id: string;
|
||||
character: string;
|
||||
episode_count: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface TmdbCreditCrew {
|
||||
credit_id: string;
|
||||
gender?: number;
|
||||
id: number;
|
||||
name: string;
|
||||
profile_path?: string;
|
||||
job: string;
|
||||
department: string;
|
||||
}
|
||||
|
||||
export interface TmdbExternalIds {
|
||||
imdb_id?: string;
|
||||
freebase_mid?: string;
|
||||
freebase_id?: string;
|
||||
tvdb_id?: number;
|
||||
tvrage_id?: string;
|
||||
facebook_id?: string;
|
||||
instagram_id?: string;
|
||||
twitter_id?: string;
|
||||
}
|
||||
|
||||
export interface TmdbProductionCompany {
|
||||
id: number;
|
||||
logo_path?: string;
|
||||
name: string;
|
||||
origin_country: string;
|
||||
homepage?: string;
|
||||
headquarters?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TmdbMovieDetails {
|
||||
id: number;
|
||||
imdb_id?: string;
|
||||
adult: boolean;
|
||||
backdrop_path?: string;
|
||||
poster_path?: string;
|
||||
budget: number;
|
||||
genres: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
homepage?: string;
|
||||
original_language: string;
|
||||
original_title: string;
|
||||
overview?: string;
|
||||
popularity: number;
|
||||
production_companies: TmdbProductionCompany[];
|
||||
production_countries: {
|
||||
iso_3166_1: string;
|
||||
name: string;
|
||||
}[];
|
||||
release_date: string;
|
||||
release_dates: TmdbMovieReleaseResult;
|
||||
revenue: number;
|
||||
runtime?: number;
|
||||
spoken_languages: {
|
||||
iso_639_1: string;
|
||||
name: string;
|
||||
}[];
|
||||
status: string;
|
||||
tagline?: string;
|
||||
title: string;
|
||||
video: boolean;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
credits: {
|
||||
cast: TmdbCreditCast[];
|
||||
crew: TmdbCreditCrew[];
|
||||
};
|
||||
belongs_to_collection?: {
|
||||
id: number;
|
||||
name: string;
|
||||
poster_path?: string;
|
||||
backdrop_path?: string;
|
||||
};
|
||||
external_ids: TmdbExternalIds;
|
||||
videos: TmdbVideoResult;
|
||||
}
|
||||
|
||||
export interface TmdbVideo {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
site: 'YouTube';
|
||||
size: number;
|
||||
type:
|
||||
| 'Clip'
|
||||
| 'Teaser'
|
||||
| 'Trailer'
|
||||
| 'Featurette'
|
||||
| 'Opening Credits'
|
||||
| 'Behind the Scenes'
|
||||
| 'Bloopers';
|
||||
}
|
||||
|
||||
export interface TmdbTvEpisodeResult {
|
||||
id: number;
|
||||
air_date: string;
|
||||
episode_number: number;
|
||||
name: string;
|
||||
overview: string;
|
||||
production_code: string;
|
||||
season_number: number;
|
||||
show_id: number;
|
||||
still_path: string;
|
||||
vote_average: number;
|
||||
vote_cuont: number;
|
||||
}
|
||||
|
||||
export interface TmdbTvSeasonResult {
|
||||
id: number;
|
||||
air_date: string;
|
||||
episode_count: number;
|
||||
name: string;
|
||||
overview: string;
|
||||
poster_path?: string;
|
||||
season_number: number;
|
||||
}
|
||||
|
||||
export interface TmdbTvDetails {
|
||||
id: number;
|
||||
backdrop_path?: string;
|
||||
content_ratings: TmdbTvRatingResult;
|
||||
created_by: {
|
||||
id: number;
|
||||
credit_id: string;
|
||||
name: string;
|
||||
gender: number;
|
||||
profile_path?: string;
|
||||
}[];
|
||||
episode_run_time: number[];
|
||||
first_air_date: string;
|
||||
genres: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
homepage: string;
|
||||
in_production: boolean;
|
||||
languages: string[];
|
||||
last_air_date: string;
|
||||
last_episode_to_air?: TmdbTvEpisodeResult;
|
||||
name: string;
|
||||
next_episode_to_air?: TmdbTvEpisodeResult;
|
||||
networks: TmdbNetwork[];
|
||||
number_of_episodes: number;
|
||||
number_of_seasons: number;
|
||||
origin_country: string[];
|
||||
original_language: string;
|
||||
original_name: string;
|
||||
overview: string;
|
||||
popularity: number;
|
||||
poster_path?: string;
|
||||
production_companies: {
|
||||
id: number;
|
||||
logo_path?: string;
|
||||
name: string;
|
||||
origin_country: string;
|
||||
}[];
|
||||
spoken_languages: {
|
||||
english_name: string;
|
||||
iso_639_1: string;
|
||||
name: string;
|
||||
}[];
|
||||
seasons: TmdbTvSeasonResult[];
|
||||
status: string;
|
||||
tagline?: string;
|
||||
type: string;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
aggregate_credits: {
|
||||
cast: TmdbAggregateCreditCast[];
|
||||
};
|
||||
credits: {
|
||||
crew: TmdbCreditCrew[];
|
||||
};
|
||||
external_ids: TmdbExternalIds;
|
||||
keywords: {
|
||||
results: TmdbKeyword[];
|
||||
};
|
||||
videos: TmdbVideoResult;
|
||||
}
|
||||
|
||||
export interface TmdbVideoResult {
|
||||
results: TmdbVideo[];
|
||||
}
|
||||
|
||||
export interface TmdbTvRatingResult {
|
||||
results: TmdbRating[];
|
||||
}
|
||||
|
||||
export interface TmdbRating {
|
||||
iso_3166_1: string;
|
||||
rating: string;
|
||||
}
|
||||
|
||||
export interface TmdbMovieReleaseResult {
|
||||
results: TmdbRelease[];
|
||||
}
|
||||
|
||||
export interface TmdbRelease extends TmdbRating {
|
||||
release_dates: {
|
||||
certification: string;
|
||||
iso_639_1?: string;
|
||||
note?: string;
|
||||
release_date: string;
|
||||
type: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface TmdbKeyword {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TmdbPersonDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
deathday: string;
|
||||
known_for_department: string;
|
||||
also_known_as?: string[];
|
||||
gender: number;
|
||||
biography: string;
|
||||
popularity: string;
|
||||
place_of_birth?: string;
|
||||
profile_path?: string;
|
||||
adult: boolean;
|
||||
imdb_id?: string;
|
||||
homepage?: string;
|
||||
}
|
||||
|
||||
export interface TmdbPersonCredit {
|
||||
id: number;
|
||||
original_language: string;
|
||||
episode_count: number;
|
||||
overview: string;
|
||||
origin_country: string[];
|
||||
original_name: string;
|
||||
vote_count: number;
|
||||
name: string;
|
||||
media_type?: string;
|
||||
popularity: number;
|
||||
credit_id: string;
|
||||
backdrop_path?: string;
|
||||
first_air_date: string;
|
||||
vote_average: number;
|
||||
genre_ids?: number[];
|
||||
poster_path?: string;
|
||||
original_title: string;
|
||||
video?: boolean;
|
||||
title: string;
|
||||
adult: boolean;
|
||||
release_date: string;
|
||||
}
|
||||
|
||||
export interface TmdbPersonCreditCast extends TmdbPersonCredit {
|
||||
character: string;
|
||||
}
|
||||
|
||||
export interface TmdbPersonCreditCrew extends TmdbPersonCredit {
|
||||
department: string;
|
||||
job: string;
|
||||
}
|
||||
|
||||
export interface TmdbPersonCombinedCredits {
|
||||
id: number;
|
||||
cast: TmdbPersonCreditCast[];
|
||||
crew: TmdbPersonCreditCrew[];
|
||||
}
|
||||
|
||||
export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult {
|
||||
episodes: TmdbTvEpisodeResult[];
|
||||
external_ids: TmdbExternalIds;
|
||||
}
|
||||
|
||||
export interface TmdbCollection {
|
||||
id: number;
|
||||
name: string;
|
||||
overview?: string;
|
||||
poster_path?: string;
|
||||
backdrop_path?: string;
|
||||
parts: TmdbMovieResult[];
|
||||
}
|
||||
|
||||
export interface TmdbRegion {
|
||||
iso_3166_1: string;
|
||||
english_name: string;
|
||||
}
|
||||
|
||||
export interface TmdbLanguage {
|
||||
iso_639_1: string;
|
||||
english_name: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TmdbGenresResult {
|
||||
genres: TmdbGenre[];
|
||||
}
|
||||
|
||||
export interface TmdbGenre {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TmdbNetwork {
|
||||
id: number;
|
||||
name: string;
|
||||
headquarters?: string;
|
||||
homepage?: string;
|
||||
logo_path?: string;
|
||||
origin_country?: string;
|
||||
}
|
||||
@@ -2,7 +2,6 @@ export enum MediaRequestStatus {
|
||||
PENDING = 1,
|
||||
APPROVED,
|
||||
DECLINED,
|
||||
AVAILABLE,
|
||||
}
|
||||
|
||||
export enum MediaType {
|
||||
|
||||
@@ -8,11 +8,16 @@ import {
|
||||
UpdateDateColumn,
|
||||
getRepository,
|
||||
In,
|
||||
AfterLoad,
|
||||
} from 'typeorm';
|
||||
import { MediaRequest } from './MediaRequest';
|
||||
import { MediaStatus, MediaType } from '../constants/media';
|
||||
import logger from '../logger';
|
||||
import Season from './Season';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import RadarrAPI from '../api/radarr';
|
||||
import downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
|
||||
import SonarrAPI from '../api/sonarr';
|
||||
|
||||
@Entity()
|
||||
class Media {
|
||||
@@ -104,9 +109,170 @@ class Media {
|
||||
@Column({ type: 'datetime', nullable: true })
|
||||
public mediaAddedAt: Date;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public serviceId?: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public serviceId4k?: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public externalServiceId?: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public externalServiceId4k?: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public externalServiceSlug?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public externalServiceSlug4k?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public ratingKey?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public ratingKey4k?: string;
|
||||
|
||||
public serviceUrl?: string;
|
||||
public serviceUrl4k?: string;
|
||||
public downloadStatus?: DownloadingItem[] = [];
|
||||
public downloadStatus4k?: DownloadingItem[] = [];
|
||||
|
||||
public plexUrl?: string;
|
||||
public plexUrl4k?: string;
|
||||
|
||||
constructor(init?: Partial<Media>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
@AfterLoad()
|
||||
public setPlexUrls(): void {
|
||||
const machineId = getSettings().plex.machineId;
|
||||
if (this.ratingKey) {
|
||||
this.plexUrl = `https://app.plex.tv/desktop#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${this.ratingKey}`;
|
||||
}
|
||||
if (this.ratingKey4k) {
|
||||
this.plexUrl4k = `https://app.plex.tv/desktop#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${this.ratingKey4k}`;
|
||||
}
|
||||
}
|
||||
|
||||
@AfterLoad()
|
||||
public setServiceUrl(): void {
|
||||
if (this.mediaType === MediaType.MOVIE) {
|
||||
if (this.serviceId !== null && this.externalServiceSlug !== null) {
|
||||
const settings = getSettings();
|
||||
const server = settings.radarr.find(
|
||||
(radarr) => radarr.id === this.serviceId
|
||||
);
|
||||
|
||||
if (server) {
|
||||
this.serviceUrl = server.externalUrl
|
||||
? `${server.externalUrl}/movie/${this.externalServiceSlug}`
|
||||
: RadarrAPI.buildRadarrUrl(
|
||||
server,
|
||||
`/movie/${this.externalServiceSlug}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.serviceId4k !== null && this.externalServiceSlug4k !== null) {
|
||||
const settings = getSettings();
|
||||
const server = settings.radarr.find(
|
||||
(radarr) => radarr.id === this.serviceId4k
|
||||
);
|
||||
|
||||
if (server) {
|
||||
this.serviceUrl4k = server.externalUrl
|
||||
? `${server.externalUrl}/movie/${this.externalServiceSlug4k}`
|
||||
: RadarrAPI.buildRadarrUrl(
|
||||
server,
|
||||
`/movie/${this.externalServiceSlug4k}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.mediaType === MediaType.TV) {
|
||||
if (this.serviceId !== null && this.externalServiceSlug !== null) {
|
||||
const settings = getSettings();
|
||||
const server = settings.sonarr.find(
|
||||
(sonarr) => sonarr.id === this.serviceId
|
||||
);
|
||||
|
||||
if (server) {
|
||||
this.serviceUrl = server.externalUrl
|
||||
? `${server.externalUrl}/series/${this.externalServiceSlug}`
|
||||
: SonarrAPI.buildSonarrUrl(
|
||||
server,
|
||||
`/series/${this.externalServiceSlug}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.serviceId4k !== null && this.externalServiceSlug4k !== null) {
|
||||
const settings = getSettings();
|
||||
const server = settings.sonarr.find(
|
||||
(sonarr) => sonarr.id === this.serviceId4k
|
||||
);
|
||||
|
||||
if (server) {
|
||||
this.serviceUrl4k = server.externalUrl
|
||||
? `${server.externalUrl}/series/${this.externalServiceSlug4k}`
|
||||
: SonarrAPI.buildSonarrUrl(
|
||||
server,
|
||||
`/series/${this.externalServiceSlug4k}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AfterLoad()
|
||||
public getDownloadingItem(): void {
|
||||
if (this.mediaType === MediaType.MOVIE) {
|
||||
if (
|
||||
this.externalServiceId !== undefined &&
|
||||
this.serviceId !== undefined
|
||||
) {
|
||||
this.downloadStatus = downloadTracker.getMovieProgress(
|
||||
this.serviceId,
|
||||
this.externalServiceId
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.externalServiceId4k !== undefined &&
|
||||
this.serviceId4k !== undefined
|
||||
) {
|
||||
this.downloadStatus4k = downloadTracker.getMovieProgress(
|
||||
this.serviceId4k,
|
||||
this.externalServiceId4k
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.mediaType === MediaType.TV) {
|
||||
if (
|
||||
this.externalServiceId !== undefined &&
|
||||
this.serviceId !== undefined
|
||||
) {
|
||||
this.downloadStatus = downloadTracker.getSeriesProgress(
|
||||
this.serviceId,
|
||||
this.externalServiceId
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.externalServiceId4k !== undefined &&
|
||||
this.serviceId4k !== undefined
|
||||
) {
|
||||
this.downloadStatus4k = downloadTracker.getSeriesProgress(
|
||||
this.serviceId4k,
|
||||
this.externalServiceId4k
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Media;
|
||||
|
||||
@@ -15,7 +15,8 @@ import { User } from './User';
|
||||
import Media from './Media';
|
||||
import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import TheMovieDb, { ANIME_KEYWORD_ID } from '../api/themoviedb';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants';
|
||||
import RadarrAPI from '../api/radarr';
|
||||
import logger from '../logger';
|
||||
import SeasonRequest from './SeasonRequest';
|
||||
@@ -77,6 +78,9 @@ export class MediaRequest {
|
||||
@Column({ nullable: true })
|
||||
public rootFolder: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public languageProfileId: number;
|
||||
|
||||
constructor(init?: Partial<MediaRequest>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
@@ -107,6 +111,7 @@ export class MediaRequest {
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
notifyUser: this.requestedBy,
|
||||
media,
|
||||
request: this,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -126,6 +131,7 @@ export class MediaRequest {
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
request: this,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -138,7 +144,7 @@ export class MediaRequest {
|
||||
* auto approved content
|
||||
*/
|
||||
@AfterUpdate()
|
||||
public async notifyApprovedOrDeclined(): Promise<void> {
|
||||
public async notifyApprovedOrDeclined(autoApproved = false): Promise<void> {
|
||||
if (
|
||||
this.status === MediaRequestStatus.APPROVED ||
|
||||
this.status === MediaRequestStatus.DECLINED
|
||||
@@ -151,12 +157,23 @@ export class MediaRequest {
|
||||
logger.error('No parent media!', { label: 'Media Request' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE) {
|
||||
logger.warn(
|
||||
'Media became available before request was approved. Approval notification will be skipped.',
|
||||
{ label: 'Media Request' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tmdb = new TheMovieDb();
|
||||
if (this.media.mediaType === MediaType.MOVIE) {
|
||||
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
|
||||
notificationManager.sendNotification(
|
||||
this.status === MediaRequestStatus.APPROVED
|
||||
? Notification.MEDIA_APPROVED
|
||||
? autoApproved
|
||||
? Notification.MEDIA_AUTO_APPROVED
|
||||
: Notification.MEDIA_APPROVED
|
||||
: Notification.MEDIA_DECLINED,
|
||||
{
|
||||
subject: movie.title,
|
||||
@@ -164,13 +181,16 @@ export class MediaRequest {
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
notifyUser: this.requestedBy,
|
||||
media,
|
||||
request: this,
|
||||
}
|
||||
);
|
||||
} else if (this.media.mediaType === MediaType.TV) {
|
||||
const tv = await tmdb.getTvShow({ tvId: this.media.tmdbId });
|
||||
notificationManager.sendNotification(
|
||||
this.status === MediaRequestStatus.APPROVED
|
||||
? Notification.MEDIA_APPROVED
|
||||
? autoApproved
|
||||
? Notification.MEDIA_AUTO_APPROVED
|
||||
: Notification.MEDIA_APPROVED
|
||||
: Notification.MEDIA_DECLINED,
|
||||
{
|
||||
subject: tv.name,
|
||||
@@ -186,12 +206,20 @@ export class MediaRequest {
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
request: this,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AfterInsert()
|
||||
public async autoapprovalNotification(): Promise<void> {
|
||||
if (this.status === MediaRequestStatus.APPROVED) {
|
||||
this.notifyApprovedOrDeclined(true);
|
||||
}
|
||||
}
|
||||
|
||||
@AfterUpdate()
|
||||
@AfterInsert()
|
||||
public async updateParentStatus(): Promise<void> {
|
||||
@@ -205,7 +233,13 @@ export class MediaRequest {
|
||||
return;
|
||||
}
|
||||
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||
if (this.status === MediaRequestStatus.APPROVED) {
|
||||
if (
|
||||
this.status === MediaRequestStatus.APPROVED &&
|
||||
// Do not update the status if the item is already partially available or available
|
||||
media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE &&
|
||||
media[this.is4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.PARTIALLY_AVAILABLE
|
||||
) {
|
||||
if (this.is4k) {
|
||||
media.status4k = MediaStatus.PROCESSING;
|
||||
} else {
|
||||
@@ -352,12 +386,25 @@ export class MediaRequest {
|
||||
const tmdb = new TheMovieDb();
|
||||
const radarr = new RadarrAPI({
|
||||
apiKey: radarrSettings.apiKey,
|
||||
url: `${radarrSettings.useSsl ? 'https' : 'http'}://${
|
||||
radarrSettings.hostname
|
||||
}:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`,
|
||||
url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'),
|
||||
});
|
||||
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: this.media.id },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
logger.error('Media not present');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
) {
|
||||
throw new Error('Media already available');
|
||||
}
|
||||
|
||||
// Run this asynchronously so we don't wait for it on the UI side
|
||||
radarr
|
||||
.addMovie({
|
||||
@@ -369,44 +416,56 @@ export class MediaRequest {
|
||||
tmdbId: movie.id,
|
||||
year: Number(movie.release_date.slice(0, 4)),
|
||||
monitored: true,
|
||||
searchNow: true,
|
||||
searchNow: !radarrSettings.preventSearch,
|
||||
})
|
||||
.then(async (success) => {
|
||||
if (!success) {
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: this.media.id },
|
||||
});
|
||||
if (!media) {
|
||||
logger.error('Media not present');
|
||||
return;
|
||||
}
|
||||
media.status = MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
logger.warn(
|
||||
'Newly added movie request failed to add to Radarr, marking as unknown',
|
||||
{
|
||||
label: 'Media Request',
|
||||
}
|
||||
);
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOneOrFail({
|
||||
select: ['id', 'plexToken'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
|
||||
subject: movie.title,
|
||||
message: 'Movie failed to add to Radarr',
|
||||
notifyUser: admin,
|
||||
media,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
});
|
||||
.then(async (radarrMovie) => {
|
||||
// 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 },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
throw new Error('Media data is missing');
|
||||
}
|
||||
|
||||
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||
radarrMovie.id;
|
||||
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
||||
radarrMovie.titleSlug;
|
||||
media[this.is4k ? 'serviceId4k' : 'serviceId'] = radarrSettings?.id;
|
||||
await mediaRepository.save(media);
|
||||
})
|
||||
.catch(async () => {
|
||||
media.status = MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
logger.warn(
|
||||
'Newly added movie request failed to add to Radarr, marking as unknown',
|
||||
{
|
||||
label: 'Media Request',
|
||||
}
|
||||
);
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOneOrFail({
|
||||
select: ['id', 'plexToken'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
|
||||
subject: movie.title,
|
||||
message: 'Movie failed to add to Radarr',
|
||||
notifyUser: admin,
|
||||
media,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
request: this,
|
||||
});
|
||||
});
|
||||
logger.info('Sent request to Radarr', { label: 'Media Request' });
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[MediaRequest] Request failed to send to radarr: ${e.message}`
|
||||
);
|
||||
const errorMessage = `Request failed to send to radarr: ${e.message}`;
|
||||
logger.error('Request failed to send to Radarr', {
|
||||
label: 'Media Request',
|
||||
errorMessage,
|
||||
});
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -464,16 +523,24 @@ export class MediaRequest {
|
||||
throw new Error('Media data is missing');
|
||||
}
|
||||
|
||||
if (
|
||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
) {
|
||||
throw new Error('Media already available');
|
||||
}
|
||||
|
||||
const tmdb = new TheMovieDb();
|
||||
const sonarr = new SonarrAPI({
|
||||
apiKey: sonarrSettings.apiKey,
|
||||
url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${
|
||||
sonarrSettings.hostname
|
||||
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`,
|
||||
url: SonarrAPI.buildSonarrUrl(sonarrSettings, '/api/v3'),
|
||||
});
|
||||
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
|
||||
|
||||
if (!series.external_ids.tvdb_id) {
|
||||
if (!tvdbId) {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
await mediaRepository.remove(media);
|
||||
await requestRepository.remove(this);
|
||||
throw new Error('Series was missing tvdb id');
|
||||
}
|
||||
|
||||
@@ -497,6 +564,11 @@ export class MediaRequest {
|
||||
? sonarrSettings.activeAnimeProfileId
|
||||
: sonarrSettings.activeProfileId;
|
||||
|
||||
let languageProfile =
|
||||
seriesType === 'anime' && sonarrSettings.activeAnimeLanguageProfileId
|
||||
? sonarrSettings.activeAnimeLanguageProfileId
|
||||
: sonarrSettings.activeLanguageProfileId;
|
||||
|
||||
if (
|
||||
this.rootFolder &&
|
||||
this.rootFolder !== '' &&
|
||||
@@ -510,60 +582,94 @@ export class MediaRequest {
|
||||
|
||||
if (this.profileId && this.profileId !== qualityProfile) {
|
||||
qualityProfile = this.profileId;
|
||||
logger.info(`Request has an override profile id: ${qualityProfile}`, {
|
||||
logger.info(`Request has an override profile ID: ${qualityProfile}`, {
|
||||
label: 'Media Request',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
this.languageProfileId &&
|
||||
this.languageProfileId !== languageProfile
|
||||
) {
|
||||
languageProfile = this.languageProfileId;
|
||||
logger.info(
|
||||
`Request has an override Language Profile: ${languageProfile}`,
|
||||
{
|
||||
label: 'Media Request',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Run this asynchronously so we don't wait for it on the UI side
|
||||
sonarr
|
||||
.addSeries({
|
||||
profileId: qualityProfile,
|
||||
languageProfileId: languageProfile,
|
||||
rootFolderPath: rootFolder,
|
||||
title: series.name,
|
||||
tvdbid: series.external_ids.tvdb_id,
|
||||
tvdbid: tvdbId,
|
||||
seasons: this.seasons.map((season) => season.seasonNumber),
|
||||
seasonFolder: sonarrSettings.enableSeasonFolders,
|
||||
seriesType,
|
||||
monitored: true,
|
||||
searchNow: true,
|
||||
searchNow: !sonarrSettings.preventSearch,
|
||||
})
|
||||
.then(async (success) => {
|
||||
if (!success) {
|
||||
media.status = MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
logger.warn(
|
||||
'Newly added series request failed to add to Sonarr, marking as unknown',
|
||||
{
|
||||
label: 'Media Request',
|
||||
}
|
||||
);
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOneOrFail({
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
|
||||
subject: series.name,
|
||||
message: 'Series failed to add to Sonarr',
|
||||
notifyUser: admin,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${series.poster_path}`,
|
||||
media,
|
||||
extra: [
|
||||
{
|
||||
name: 'Seasons',
|
||||
value: this.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
});
|
||||
.then(async (sonarrSeries) => {
|
||||
// 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'],
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
throw new Error('Media data is missing');
|
||||
}
|
||||
|
||||
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||
sonarrSeries.id;
|
||||
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
||||
sonarrSeries.titleSlug;
|
||||
media[this.is4k ? 'serviceId4k' : 'serviceId'] = sonarrSettings?.id;
|
||||
await mediaRepository.save(media);
|
||||
})
|
||||
.catch(async () => {
|
||||
media.status = MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
logger.warn(
|
||||
'Newly added series request failed to add to Sonarr, marking as unknown',
|
||||
{
|
||||
label: 'Media Request',
|
||||
}
|
||||
);
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOneOrFail({
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
|
||||
subject: series.name,
|
||||
message: 'Series failed to add to Sonarr',
|
||||
notifyUser: admin,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${series.poster_path}`,
|
||||
media,
|
||||
extra: [
|
||||
{
|
||||
name: 'Seasons',
|
||||
value: this.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
request: this,
|
||||
});
|
||||
});
|
||||
logger.info('Sent request to Sonarr', { label: 'Media Request' });
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[MediaRequest] Request failed to send to sonarr: ${e.message}`
|
||||
);
|
||||
const errorMessage = `Request failed to send to sonarr: ${e.message}`;
|
||||
logger.error('Request failed to send to Sonarr', {
|
||||
label: 'Media Request',
|
||||
errorMessage,
|
||||
});
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,14 @@ import {
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
RelationCount,
|
||||
AfterLoad,
|
||||
OneToOne,
|
||||
} from 'typeorm';
|
||||
import { Permission, hasPermission } from '../lib/permissions';
|
||||
import {
|
||||
Permission,
|
||||
hasPermission,
|
||||
PermissionCheckOptions,
|
||||
} from '../lib/permissions';
|
||||
import { MediaRequest } from './MediaRequest';
|
||||
import bcrypt from 'bcrypt';
|
||||
import path from 'path';
|
||||
@@ -16,14 +22,21 @@ import logger from '../logger';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import { default as generatePassword } from 'secure-random-password';
|
||||
import { UserType } from '../constants/user';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { UserSettings } from './UserSettings';
|
||||
|
||||
@Entity()
|
||||
export class User {
|
||||
public static filterMany(users: User[]): Partial<User>[] {
|
||||
return users.map((u) => u.filter());
|
||||
public static filterMany(
|
||||
users: User[],
|
||||
showFiltered?: boolean
|
||||
): Partial<User>[] {
|
||||
return users.map((u) => u.filter(showFiltered));
|
||||
}
|
||||
|
||||
static readonly filteredFields: string[] = ['plexToken', 'password'];
|
||||
static readonly filteredFields: string[] = ['email'];
|
||||
|
||||
public displayName: string;
|
||||
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
@@ -31,12 +44,21 @@ export class User {
|
||||
@Column({ unique: true })
|
||||
public email: string;
|
||||
|
||||
@Column()
|
||||
public username: string;
|
||||
@Column({ nullable: true })
|
||||
public plexUsername: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public username?: string;
|
||||
|
||||
@Column({ nullable: true, select: false })
|
||||
public password?: string;
|
||||
|
||||
@Column({ nullable: true, select: false })
|
||||
public resetPasswordGuid?: string;
|
||||
|
||||
@Column({ type: 'date', nullable: true })
|
||||
public recoveryLinkExpirationDate?: Date | null;
|
||||
|
||||
@Column({ type: 'integer', default: UserType.PLEX })
|
||||
public userType: UserType;
|
||||
|
||||
@@ -58,6 +80,13 @@ export class User {
|
||||
@OneToMany(() => MediaRequest, (request) => request.requestedBy)
|
||||
public requests: MediaRequest[];
|
||||
|
||||
@OneToOne(() => UserSettings, (settings) => settings.user, {
|
||||
cascade: true,
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
public settings?: UserSettings;
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
|
||||
@@ -68,27 +97,30 @@ export class User {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
public filter(): Partial<User> {
|
||||
public filter(showFiltered?: boolean): Partial<User> {
|
||||
const filtered: Partial<User> = Object.assign(
|
||||
{},
|
||||
...(Object.keys(this) as (keyof User)[])
|
||||
.filter((k) => !User.filteredFields.includes(k))
|
||||
.filter((k) => showFiltered || !User.filteredFields.includes(k))
|
||||
.map((k) => ({ [k]: this[k] }))
|
||||
);
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
public hasPermission(permissions: Permission | Permission[]): boolean {
|
||||
return !!hasPermission(permissions, this.permissions);
|
||||
public hasPermission(
|
||||
permissions: Permission | Permission[],
|
||||
options?: PermissionCheckOptions
|
||||
): boolean {
|
||||
return !!hasPermission(permissions, this.permissions, options);
|
||||
}
|
||||
|
||||
public passwordMatch(password: string): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise((resolve) => {
|
||||
if (this.password) {
|
||||
resolve(bcrypt.compare(password, this.password));
|
||||
} else {
|
||||
return reject(false);
|
||||
return resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -98,31 +130,73 @@ export class User {
|
||||
this.password = hashedPassword;
|
||||
}
|
||||
|
||||
public async resetPassword(): Promise<void> {
|
||||
public async generatePassword(): Promise<void> {
|
||||
const password = generatePassword.randomPassword({ length: 16 });
|
||||
this.setPassword(password);
|
||||
|
||||
const applicationUrl = getSettings().main.applicationUrl;
|
||||
const { applicationTitle, applicationUrl } = getSettings().main;
|
||||
try {
|
||||
logger.info(`Sending password email for ${this.email}`, {
|
||||
label: 'User creation',
|
||||
logger.info(`Sending generated password email for ${this.email}`, {
|
||||
label: 'User Management',
|
||||
});
|
||||
const email = new PreparedEmail();
|
||||
await email.send({
|
||||
template: path.join(__dirname, '../templates/email/password'),
|
||||
template: path.join(__dirname, '../templates/email/generatedpassword'),
|
||||
message: {
|
||||
to: this.email,
|
||||
},
|
||||
locals: {
|
||||
password: password,
|
||||
applicationUrl,
|
||||
applicationTitle,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Failed to send out password email', {
|
||||
label: 'User creation',
|
||||
logger.error('Failed to send out generated password email', {
|
||||
label: 'User Management',
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async resetPassword(): Promise<void> {
|
||||
const guid = uuid();
|
||||
this.resetPasswordGuid = guid;
|
||||
|
||||
// 24 hours into the future
|
||||
const targetDate = new Date();
|
||||
targetDate.setDate(targetDate.getDate() + 1);
|
||||
this.recoveryLinkExpirationDate = targetDate;
|
||||
|
||||
const { applicationTitle, applicationUrl } = getSettings().main;
|
||||
const resetPasswordLink = `${applicationUrl}/resetpassword/${guid}`;
|
||||
|
||||
try {
|
||||
logger.info(`Sending reset password email for ${this.email}`, {
|
||||
label: 'User Management',
|
||||
});
|
||||
const email = new PreparedEmail();
|
||||
await email.send({
|
||||
template: path.join(__dirname, '../templates/email/resetpassword'),
|
||||
message: {
|
||||
to: this.email,
|
||||
},
|
||||
locals: {
|
||||
resetPasswordLink,
|
||||
applicationUrl: resetPasswordLink,
|
||||
applicationTitle,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Failed to send out reset password email', {
|
||||
label: 'User Management',
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@AfterLoad()
|
||||
public setDisplayName(): void {
|
||||
this.displayName = this.username || this.plexUsername;
|
||||
}
|
||||
}
|
||||
|
||||
43
server/entity/UserSettings.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from './User';
|
||||
|
||||
@Entity()
|
||||
export class UserSettings {
|
||||
constructor(init?: Partial<UserSettings>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@OneToOne(() => User, (user) => user.settings, { onDelete: 'CASCADE' })
|
||||
@JoinColumn()
|
||||
public user: User;
|
||||
|
||||
@Column({ default: true })
|
||||
public enableNotifications: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public discordId?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public telegramChatId?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public telegramSendSilently?: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public region?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public originalLanguage?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public pgpKey?: string;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { createConnection, getRepository } from 'typeorm';
|
||||
import routes from './routes';
|
||||
import bodyParser from 'body-parser';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import csurf from 'csurf';
|
||||
import session, { Store } from 'express-session';
|
||||
import { TypeormStore } from 'connect-typeorm/out';
|
||||
import YAML from 'yamljs';
|
||||
@@ -22,6 +23,8 @@ import { getAppVersion } from './utils/appVersion';
|
||||
import SlackAgent from './lib/notifications/agents/slack';
|
||||
import PushoverAgent from './lib/notifications/agents/pushover';
|
||||
import WebhookAgent from './lib/notifications/agents/webhook';
|
||||
import { getClientIp } from '@supercharge/request-ip';
|
||||
import PushbulletAgent from './lib/notifications/agents/pushbullet';
|
||||
|
||||
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
|
||||
|
||||
@@ -49,9 +52,10 @@ app
|
||||
notificationManager.registerAgents([
|
||||
new DiscordAgent(),
|
||||
new EmailAgent(),
|
||||
new PushbulletAgent(),
|
||||
new PushoverAgent(),
|
||||
new SlackAgent(),
|
||||
new TelegramAgent(),
|
||||
new PushoverAgent(),
|
||||
new WebhookAgent(),
|
||||
]);
|
||||
|
||||
@@ -59,11 +63,47 @@ app
|
||||
startJobs();
|
||||
|
||||
const server = express();
|
||||
if (settings.main.trustProxy) {
|
||||
server.enable('trust proxy');
|
||||
}
|
||||
server.use(cookieParser());
|
||||
server.use(bodyParser.json());
|
||||
server.use(bodyParser.urlencoded({ extended: true }));
|
||||
server.use((req, res, next) => {
|
||||
try {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(req, 'ip');
|
||||
if (descriptor?.writable === true) {
|
||||
req.ip = getClientIp(req) ?? '';
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to attach the ip to the request', {
|
||||
label: 'Middleware',
|
||||
message: e.message,
|
||||
});
|
||||
} finally {
|
||||
next();
|
||||
}
|
||||
});
|
||||
if (settings.main.csrfProtection) {
|
||||
server.use(
|
||||
csurf({
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
sameSite: true,
|
||||
secure: !dev,
|
||||
},
|
||||
})
|
||||
);
|
||||
server.use((req, res, next) => {
|
||||
res.cookie('XSRF-TOKEN', req.csrfToken(), {
|
||||
sameSite: true,
|
||||
secure: !dev,
|
||||
});
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
// Setup sessions
|
||||
// Set up sessions
|
||||
const sessionRespository = getRepository(Session);
|
||||
server.use(
|
||||
'/api',
|
||||
|
||||
45
server/interfaces/api/plexInterfaces.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { PlexSettings } from '../../lib/settings';
|
||||
|
||||
export interface PlexStatus {
|
||||
settings: PlexSettings;
|
||||
status: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PlexConnection {
|
||||
protocol: string;
|
||||
address: string;
|
||||
port: number;
|
||||
uri: string;
|
||||
local: boolean;
|
||||
status?: number;
|
||||
message?: string;
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export interface PlexDevice {
|
||||
name: string;
|
||||
product: string;
|
||||
productVersion: string;
|
||||
platform: string;
|
||||
platformVersion: string;
|
||||
device: string;
|
||||
clientIdentifier: string;
|
||||
createdAt: Date;
|
||||
lastSeenAt: Date;
|
||||
provides: string[];
|
||||
owned: boolean;
|
||||
accessToken?: string;
|
||||
publicAddress?: string;
|
||||
httpsRequired?: boolean;
|
||||
synced?: boolean;
|
||||
relay?: boolean;
|
||||
dnsRebindingProtection?: boolean;
|
||||
natLoopbackSupported?: boolean;
|
||||
publicAddressMatches?: boolean;
|
||||
presence?: boolean;
|
||||
ownerID?: string;
|
||||
home?: boolean;
|
||||
sourceTitle?: string;
|
||||
connection: PlexConnection[];
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RadarrProfile, RadarrRootFolder } from '../../api/radarr';
|
||||
import { LanguageProfile } from '../../api/sonarr';
|
||||
|
||||
export interface ServiceCommonServer {
|
||||
id: number;
|
||||
@@ -7,12 +8,15 @@ export interface ServiceCommonServer {
|
||||
isDefault: boolean;
|
||||
activeProfileId: number;
|
||||
activeDirectory: string;
|
||||
activeLanguageProfileId?: number;
|
||||
activeAnimeProfileId?: number;
|
||||
activeAnimeDirectory?: string;
|
||||
activeAnimeLanguageProfileId?: number;
|
||||
}
|
||||
|
||||
export interface ServiceCommonServerWithDetails {
|
||||
server: ServiceCommonServer;
|
||||
profiles: RadarrProfile[];
|
||||
rootFolders: Partial<RadarrRootFolder>[];
|
||||
languageProfiles?: LanguageProfile[];
|
||||
}
|
||||
|
||||
@@ -7,6 +7,23 @@ export interface SettingsAboutResponse {
|
||||
|
||||
export interface PublicSettingsResponse {
|
||||
initialized: boolean;
|
||||
applicationTitle: string;
|
||||
hideAvailable: boolean;
|
||||
localLogin: boolean;
|
||||
movie4kEnabled: boolean;
|
||||
series4kEnabled: boolean;
|
||||
region: string;
|
||||
originalLanguage: string;
|
||||
}
|
||||
|
||||
export interface CacheItem {
|
||||
id: string;
|
||||
name: string;
|
||||
stats: {
|
||||
hits: number;
|
||||
misses: number;
|
||||
keys: number;
|
||||
ksize: number;
|
||||
vsize: number;
|
||||
};
|
||||
}
|
||||
|
||||
11
server/interfaces/api/userInterfaces.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { User } from '../../entity/User';
|
||||
import { MediaRequest } from '../../entity/MediaRequest';
|
||||
import { PaginatedResponse } from './common';
|
||||
|
||||
export interface UserResultsResponse extends PaginatedResponse {
|
||||
results: User[];
|
||||
}
|
||||
|
||||
export interface UserRequestsResponse extends PaginatedResponse {
|
||||
results: MediaRequest[];
|
||||
}
|
||||
14
server/interfaces/api/userSettingsInterfaces.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface UserSettingsGeneralResponse {
|
||||
username?: string;
|
||||
region?: string;
|
||||
originalLanguage?: string;
|
||||
}
|
||||
|
||||
export interface UserSettingsNotificationsResponse {
|
||||
enableNotifications: boolean;
|
||||
telegramBotUsername?: string;
|
||||
discordId?: string;
|
||||
telegramChatId?: string;
|
||||
telegramSendSilently?: boolean;
|
||||
pgpKey?: string;
|
||||
}
|
||||
@@ -1,797 +0,0 @@
|
||||
import { getRepository } from 'typeorm';
|
||||
import { User } from '../../entity/User';
|
||||
import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../api/plexapi';
|
||||
import TheMovieDb, {
|
||||
TmdbMovieDetails,
|
||||
TmdbTvDetails,
|
||||
} from '../../api/themoviedb';
|
||||
import Media from '../../entity/Media';
|
||||
import { MediaStatus, MediaType } from '../../constants/media';
|
||||
import logger from '../../logger';
|
||||
import { getSettings, Library } from '../../lib/settings';
|
||||
import Season from '../../entity/Season';
|
||||
import { uniqWith } from 'lodash';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import animeList from '../../api/animelist';
|
||||
import AsyncLock from '../../utils/asyncLock';
|
||||
|
||||
const BUNDLE_SIZE = 20;
|
||||
const UPDATE_RATE = 4 * 1000;
|
||||
|
||||
const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
|
||||
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
|
||||
const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/);
|
||||
const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/);
|
||||
const plexRegex = new RegExp(/plex:\/\//);
|
||||
// Hama agent uses ASS naming, see details here:
|
||||
// https://github.com/ZeroQI/Absolute-Series-Scanner/blob/master/README.md#forcing-the-movieseries-id
|
||||
const hamaTvdbRegex = new RegExp(/hama:\/\/tvdb[0-9]?-([0-9]+)/);
|
||||
const hamaAnidbRegex = new RegExp(/hama:\/\/anidb[0-9]?-([0-9]+)/);
|
||||
const HAMA_AGENT = 'com.plexapp.agents.hama';
|
||||
|
||||
interface SyncStatus {
|
||||
running: boolean;
|
||||
progress: number;
|
||||
total: number;
|
||||
currentLibrary: Library;
|
||||
libraries: Library[];
|
||||
}
|
||||
|
||||
class JobPlexSync {
|
||||
private sessionId: string;
|
||||
private tmdb: TheMovieDb;
|
||||
private plexClient: PlexAPI;
|
||||
private items: PlexLibraryItem[] = [];
|
||||
private progress = 0;
|
||||
private libraries: Library[];
|
||||
private currentLibrary: Library;
|
||||
private running = false;
|
||||
private isRecentOnly = false;
|
||||
private enable4kMovie = false;
|
||||
private enable4kShow = false;
|
||||
private asyncLock = new AsyncLock();
|
||||
|
||||
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
|
||||
this.tmdb = new TheMovieDb();
|
||||
this.isRecentOnly = isRecentOnly ?? false;
|
||||
}
|
||||
|
||||
private async getExisting(tmdbId: number, mediaType: MediaType) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const existing = await mediaRepository.findOne({
|
||||
where: { tmdbId: tmdbId, mediaType },
|
||||
});
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
private async processMovie(plexitem: PlexLibraryItem) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
try {
|
||||
if (plexitem.guid.match(plexRegex)) {
|
||||
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
|
||||
const newMedia = new Media();
|
||||
|
||||
if (!metadata.Guid) {
|
||||
logger.debug('No Guid metadata for this title. Skipping', {
|
||||
label: 'Plex Sync',
|
||||
ratingKey: plexitem.ratingKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
metadata.Guid.forEach((ref) => {
|
||||
if (ref.id.match(imdbRegex)) {
|
||||
newMedia.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined;
|
||||
} else if (ref.id.match(tmdbRegex)) {
|
||||
const tmdbMatch = ref.id.match(tmdbRegex)?.[1];
|
||||
newMedia.tmdbId = Number(tmdbMatch);
|
||||
}
|
||||
});
|
||||
|
||||
const has4k = metadata.Media.some(
|
||||
(media) => media.videoResolution === '4k'
|
||||
);
|
||||
const hasOtherResolution = metadata.Media.some(
|
||||
(media) => media.videoResolution !== '4k'
|
||||
);
|
||||
|
||||
await this.asyncLock.dispatch(newMedia.tmdbId, async () => {
|
||||
const existing = await this.getExisting(
|
||||
newMedia.tmdbId,
|
||||
MediaType.MOVIE
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
let changedExisting = false;
|
||||
|
||||
if (
|
||||
(hasOtherResolution || (!this.enable4kMovie && has4k)) &&
|
||||
existing.status !== MediaStatus.AVAILABLE
|
||||
) {
|
||||
existing.status = MediaStatus.AVAILABLE;
|
||||
existing.mediaAddedAt = new Date(plexitem.addedAt * 1000);
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
has4k &&
|
||||
this.enable4kMovie &&
|
||||
existing.status4k !== MediaStatus.AVAILABLE
|
||||
) {
|
||||
existing.status4k = MediaStatus.AVAILABLE;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (!existing.mediaAddedAt && !changedExisting) {
|
||||
existing.mediaAddedAt = new Date(plexitem.addedAt * 1000);
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (changedExisting) {
|
||||
await mediaRepository.save(existing);
|
||||
this.log(
|
||||
`Request for ${metadata.title} exists. New media types set to AVAILABLE`,
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
this.log(
|
||||
`Title already exists and no new media types found ${metadata.title}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
newMedia.status =
|
||||
hasOtherResolution || (!this.enable4kMovie && has4k)
|
||||
? MediaStatus.AVAILABLE
|
||||
: MediaStatus.UNKNOWN;
|
||||
newMedia.status4k =
|
||||
has4k && this.enable4kMovie
|
||||
? MediaStatus.AVAILABLE
|
||||
: MediaStatus.UNKNOWN;
|
||||
newMedia.mediaType = MediaType.MOVIE;
|
||||
newMedia.mediaAddedAt = new Date(plexitem.addedAt * 1000);
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${plexitem.title}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
let tmdbMovieId: number | undefined;
|
||||
let tmdbMovie: TmdbMovieDetails | undefined;
|
||||
|
||||
const imdbMatch = plexitem.guid.match(imdbRegex);
|
||||
const tmdbMatch = plexitem.guid.match(tmdbShowRegex);
|
||||
|
||||
if (imdbMatch) {
|
||||
tmdbMovie = await this.tmdb.getMovieByImdbId({
|
||||
imdbId: imdbMatch[1],
|
||||
});
|
||||
tmdbMovieId = tmdbMovie.id;
|
||||
} else if (tmdbMatch) {
|
||||
tmdbMovieId = Number(tmdbMatch[1]);
|
||||
}
|
||||
|
||||
if (!tmdbMovieId) {
|
||||
throw new Error('Unable to find TMDB ID');
|
||||
}
|
||||
|
||||
await this.processMovieWithId(plexitem, tmdbMovie, tmdbMovieId);
|
||||
}
|
||||
} catch (e) {
|
||||
this.log(
|
||||
`Failed to process plex item. ratingKey: ${plexitem.ratingKey}`,
|
||||
'error',
|
||||
{
|
||||
errorMessage: e.message,
|
||||
plexitem,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async processMovieWithId(
|
||||
plexitem: PlexLibraryItem,
|
||||
tmdbMovie: TmdbMovieDetails | undefined,
|
||||
tmdbMovieId: number
|
||||
) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
await this.asyncLock.dispatch(tmdbMovieId, async () => {
|
||||
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
|
||||
const existing = await this.getExisting(tmdbMovieId, MediaType.MOVIE);
|
||||
|
||||
const has4k = metadata.Media.some(
|
||||
(media) => media.videoResolution === '4k'
|
||||
);
|
||||
const hasOtherResolution = metadata.Media.some(
|
||||
(media) => media.videoResolution !== '4k'
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
let changedExisting = false;
|
||||
|
||||
if (
|
||||
(hasOtherResolution || (!this.enable4kMovie && has4k)) &&
|
||||
existing.status !== MediaStatus.AVAILABLE
|
||||
) {
|
||||
existing.status = MediaStatus.AVAILABLE;
|
||||
existing.mediaAddedAt = new Date(plexitem.addedAt * 1000);
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
has4k &&
|
||||
this.enable4kMovie &&
|
||||
existing.status4k !== MediaStatus.AVAILABLE
|
||||
) {
|
||||
existing.status4k = MediaStatus.AVAILABLE;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (!existing.mediaAddedAt && !changedExisting) {
|
||||
existing.mediaAddedAt = new Date(plexitem.addedAt * 1000);
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (changedExisting) {
|
||||
await mediaRepository.save(existing);
|
||||
this.log(
|
||||
`Request for ${metadata.title} exists. New media types set to AVAILABLE`,
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
this.log(
|
||||
`Title already exists and no new media types found ${metadata.title}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// If we have a tmdb movie guid but it didn't already exist, only then
|
||||
// do we request the movie from tmdb (to reduce api requests)
|
||||
if (!tmdbMovie) {
|
||||
tmdbMovie = await this.tmdb.getMovie({ movieId: tmdbMovieId });
|
||||
}
|
||||
const newMedia = new Media();
|
||||
newMedia.imdbId = tmdbMovie.external_ids.imdb_id;
|
||||
newMedia.tmdbId = tmdbMovie.id;
|
||||
newMedia.mediaAddedAt = new Date(plexitem.addedAt * 1000);
|
||||
newMedia.status =
|
||||
hasOtherResolution || (!this.enable4kMovie && has4k)
|
||||
? MediaStatus.AVAILABLE
|
||||
: MediaStatus.UNKNOWN;
|
||||
newMedia.status4k =
|
||||
has4k && this.enable4kMovie
|
||||
? MediaStatus.AVAILABLE
|
||||
: MediaStatus.UNKNOWN;
|
||||
newMedia.mediaType = MediaType.MOVIE;
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${tmdbMovie.title}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// this adds all movie episodes from specials season for Hama agent
|
||||
private async processHamaSpecials(metadata: PlexMetadata, tvdbId: number) {
|
||||
const specials = metadata.Children?.Metadata.find(
|
||||
(md) => Number(md.index) === 0
|
||||
);
|
||||
if (specials) {
|
||||
const episodes = await this.plexClient.getChildrenMetadata(
|
||||
specials.ratingKey
|
||||
);
|
||||
if (episodes) {
|
||||
for (const episode of episodes) {
|
||||
const special = animeList.getSpecialEpisode(tvdbId, episode.index);
|
||||
if (special) {
|
||||
if (special.tmdbId) {
|
||||
await this.processMovieWithId(episode, undefined, special.tmdbId);
|
||||
} else if (special.imdbId) {
|
||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
||||
imdbId: special.imdbId,
|
||||
});
|
||||
await this.processMovieWithId(episode, tmdbMovie, tmdbMovie.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async processShow(plexitem: PlexLibraryItem) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
let tvShow: TmdbTvDetails | null = null;
|
||||
|
||||
try {
|
||||
const metadata = await this.plexClient.getMetadata(
|
||||
plexitem.grandparentRatingKey ??
|
||||
plexitem.parentRatingKey ??
|
||||
plexitem.ratingKey,
|
||||
{ includeChildren: true }
|
||||
);
|
||||
if (metadata.guid.match(tvdbRegex)) {
|
||||
const matchedtvdb = metadata.guid.match(tvdbRegex);
|
||||
|
||||
// If we can find a tvdb Id, use it to get the full tmdb show details
|
||||
if (matchedtvdb?.[1]) {
|
||||
tvShow = await this.tmdb.getShowByTvdbId({
|
||||
tvdbId: Number(matchedtvdb[1]),
|
||||
});
|
||||
}
|
||||
} else if (metadata.guid.match(tmdbShowRegex)) {
|
||||
const matchedtmdb = metadata.guid.match(tmdbShowRegex);
|
||||
|
||||
if (matchedtmdb?.[1]) {
|
||||
tvShow = await this.tmdb.getTvShow({ tvId: Number(matchedtmdb[1]) });
|
||||
}
|
||||
} else if (metadata.guid.match(hamaTvdbRegex)) {
|
||||
const matched = metadata.guid.match(hamaTvdbRegex);
|
||||
const tvdbId = matched?.[1];
|
||||
|
||||
if (tvdbId) {
|
||||
tvShow = await this.tmdb.getShowByTvdbId({ tvdbId: Number(tvdbId) });
|
||||
if (animeList.isLoaded()) {
|
||||
await this.processHamaSpecials(metadata, Number(tvdbId));
|
||||
} else {
|
||||
this.log(
|
||||
`Hama id ${plexitem.guid} detected, but library agent is not set to Hama`,
|
||||
'warn'
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (metadata.guid.match(hamaAnidbRegex)) {
|
||||
const matched = metadata.guid.match(hamaAnidbRegex);
|
||||
|
||||
if (!animeList.isLoaded()) {
|
||||
this.log(
|
||||
`Hama id ${plexitem.guid} detected, but library agent is not set to Hama`,
|
||||
'warn'
|
||||
);
|
||||
} else if (matched?.[1]) {
|
||||
const anidbId = Number(matched[1]);
|
||||
const result = animeList.getFromAnidbId(anidbId);
|
||||
|
||||
// first try to lookup tvshow by tvdbid
|
||||
if (result?.tvdbId) {
|
||||
const extResponse = await this.tmdb.getByExternalId({
|
||||
externalId: result.tvdbId,
|
||||
type: 'tvdb',
|
||||
});
|
||||
if (extResponse.tv_results[0]) {
|
||||
tvShow = await this.tmdb.getTvShow({
|
||||
tvId: extResponse.tv_results[0].id,
|
||||
});
|
||||
} else {
|
||||
this.log(
|
||||
`Missing TVDB ${result.tvdbId} entry in TMDB for AniDB ${anidbId}`
|
||||
);
|
||||
}
|
||||
await this.processHamaSpecials(metadata, result.tvdbId);
|
||||
}
|
||||
|
||||
if (!tvShow) {
|
||||
// if lookup of tvshow above failed, then try movie with tmdbid/imdbid
|
||||
// note - some tv shows have imdbid set too, that's why this need to go second
|
||||
if (result?.tmdbId) {
|
||||
return await this.processMovieWithId(
|
||||
plexitem,
|
||||
undefined,
|
||||
result.tmdbId
|
||||
);
|
||||
} else if (result?.imdbId) {
|
||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
||||
imdbId: result.imdbId,
|
||||
});
|
||||
return await this.processMovieWithId(
|
||||
plexitem,
|
||||
tmdbMovie,
|
||||
tmdbMovie.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tvShow) {
|
||||
await this.asyncLock.dispatch(tvShow.id, async () => {
|
||||
if (!tvShow) {
|
||||
// this will never execute, but typescript thinks somebody could reset tvShow from
|
||||
// outer scope back to null before this async gets called
|
||||
return;
|
||||
}
|
||||
|
||||
// Lets get the available seasons from plex
|
||||
const seasons = tvShow.seasons;
|
||||
const media = await this.getExisting(tvShow.id, MediaType.TV);
|
||||
|
||||
const newSeasons: Season[] = [];
|
||||
|
||||
const currentStandardSeasonAvailable = (
|
||||
media?.seasons.filter(
|
||||
(season) => season.status === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
const current4kSeasonAvailable = (
|
||||
media?.seasons.filter(
|
||||
(season) => season.status4k === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
for (const season of seasons) {
|
||||
const matchedPlexSeason = metadata.Children?.Metadata.find(
|
||||
(md) => Number(md.index) === season.season_number
|
||||
);
|
||||
|
||||
const existingSeason = media?.seasons.find(
|
||||
(es) => es.seasonNumber === season.season_number
|
||||
);
|
||||
|
||||
// Check if we found the matching season and it has all the available episodes
|
||||
if (matchedPlexSeason) {
|
||||
// If we have a matched plex season, get its children metadata so we can check details
|
||||
const episodes = await this.plexClient.getChildrenMetadata(
|
||||
matchedPlexSeason.ratingKey
|
||||
);
|
||||
// Total episodes that are in standard definition (not 4k)
|
||||
const totalStandard = episodes.filter((episode) =>
|
||||
episode.Media.some((media) => media.videoResolution !== '4k')
|
||||
).length;
|
||||
|
||||
// Total episodes that are in 4k
|
||||
const total4k = episodes.filter((episode) =>
|
||||
episode.Media.some((media) => media.videoResolution === '4k')
|
||||
).length;
|
||||
|
||||
if (existingSeason) {
|
||||
// These ternary statements look super confusing, but they are simply
|
||||
// setting the status to AVAILABLE if all of a type is there, partially if some,
|
||||
// and then not modifying the status if there are 0 items
|
||||
existingSeason.status =
|
||||
totalStandard === season.episode_count
|
||||
? MediaStatus.AVAILABLE
|
||||
: totalStandard > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: existingSeason.status;
|
||||
existingSeason.status4k =
|
||||
total4k === season.episode_count
|
||||
? MediaStatus.AVAILABLE
|
||||
: total4k > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: existingSeason.status4k;
|
||||
} else {
|
||||
newSeasons.push(
|
||||
new Season({
|
||||
seasonNumber: season.season_number,
|
||||
// This ternary is the same as the ones above, but it just falls back to "UNKNOWN"
|
||||
// if we dont have any items for the season
|
||||
status:
|
||||
totalStandard === season.episode_count
|
||||
? MediaStatus.AVAILABLE
|
||||
: totalStandard > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k:
|
||||
total4k === season.episode_count
|
||||
? MediaStatus.AVAILABLE
|
||||
: total4k > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove extras season. We dont count it for determining availability
|
||||
const filteredSeasons = tvShow.seasons.filter(
|
||||
(season) => season.season_number !== 0
|
||||
);
|
||||
|
||||
const isAllStandardSeasons =
|
||||
newSeasons.filter(
|
||||
(season) => season.status === MediaStatus.AVAILABLE
|
||||
).length +
|
||||
(media?.seasons.filter(
|
||||
(season) => season.status === MediaStatus.AVAILABLE
|
||||
).length ?? 0) >=
|
||||
filteredSeasons.length;
|
||||
|
||||
const isAll4kSeasons =
|
||||
newSeasons.filter(
|
||||
(season) => season.status4k === MediaStatus.AVAILABLE
|
||||
).length +
|
||||
(media?.seasons.filter(
|
||||
(season) => season.status4k === MediaStatus.AVAILABLE
|
||||
).length ?? 0) >=
|
||||
filteredSeasons.length;
|
||||
|
||||
if (media) {
|
||||
// Update existing
|
||||
media.seasons = [...media.seasons, ...newSeasons];
|
||||
|
||||
const newStandardSeasonAvailable = (
|
||||
media.seasons.filter(
|
||||
(season) => season.status === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
const new4kSeasonAvailable = (
|
||||
media.seasons.filter(
|
||||
(season) => season.status4k === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
// If at least one new season has become available, update
|
||||
// the lastSeasonChange field so we can trigger notifications
|
||||
if (newStandardSeasonAvailable > currentStandardSeasonAvailable) {
|
||||
this.log(
|
||||
`Detected ${
|
||||
newStandardSeasonAvailable - currentStandardSeasonAvailable
|
||||
} new standard season(s) for ${tvShow.name}`,
|
||||
'debug'
|
||||
);
|
||||
media.lastSeasonChange = new Date();
|
||||
media.mediaAddedAt = new Date(plexitem.addedAt * 1000);
|
||||
}
|
||||
|
||||
if (new4kSeasonAvailable > current4kSeasonAvailable) {
|
||||
this.log(
|
||||
`Detected ${
|
||||
new4kSeasonAvailable - current4kSeasonAvailable
|
||||
} new 4K season(s) for ${tvShow.name}`,
|
||||
'debug'
|
||||
);
|
||||
media.lastSeasonChange = new Date();
|
||||
}
|
||||
|
||||
if (!media.mediaAddedAt) {
|
||||
media.mediaAddedAt = new Date(plexitem.addedAt * 1000);
|
||||
}
|
||||
|
||||
media.status = isAllStandardSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: media.seasons.some(
|
||||
(season) => season.status !== MediaStatus.UNKNOWN
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN;
|
||||
media.status4k = isAll4kSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: media.seasons.some(
|
||||
(season) => season.status4k !== MediaStatus.UNKNOWN
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
this.log(`Updating existing title: ${tvShow.name}`);
|
||||
} else {
|
||||
const newMedia = new Media({
|
||||
mediaType: MediaType.TV,
|
||||
seasons: newSeasons,
|
||||
tmdbId: tvShow.id,
|
||||
tvdbId: tvShow.external_ids.tvdb_id,
|
||||
mediaAddedAt: new Date(plexitem.addedAt * 1000),
|
||||
status: isAllStandardSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: newSeasons.some(
|
||||
(season) => season.status !== MediaStatus.UNKNOWN
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k: isAll4kSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: newSeasons.some(
|
||||
(season) => season.status4k !== MediaStatus.UNKNOWN
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN,
|
||||
});
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${tvShow.name}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.log(`failed show: ${plexitem.guid}`);
|
||||
}
|
||||
} catch (e) {
|
||||
this.log(
|
||||
`Failed to process plex item. ratingKey: ${
|
||||
plexitem.grandparentRatingKey ??
|
||||
plexitem.parentRatingKey ??
|
||||
plexitem.ratingKey
|
||||
}`,
|
||||
'error',
|
||||
{
|
||||
errorMessage: e.message,
|
||||
plexitem,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async processItems(slicedItems: PlexLibraryItem[]) {
|
||||
await Promise.all(
|
||||
slicedItems.map(async (plexitem) => {
|
||||
if (plexitem.type === 'movie') {
|
||||
await this.processMovie(plexitem);
|
||||
} else if (
|
||||
plexitem.type === 'show' ||
|
||||
plexitem.type === 'episode' ||
|
||||
plexitem.type === 'season'
|
||||
) {
|
||||
await this.processShow(plexitem);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async loop({
|
||||
start = 0,
|
||||
end = BUNDLE_SIZE,
|
||||
sessionId,
|
||||
}: {
|
||||
start?: number;
|
||||
end?: number;
|
||||
sessionId?: string;
|
||||
} = {}) {
|
||||
const slicedItems = this.items.slice(start, end);
|
||||
|
||||
if (!this.running) {
|
||||
throw new Error('Sync was aborted.');
|
||||
}
|
||||
|
||||
if (this.sessionId !== sessionId) {
|
||||
throw new Error('New session was started. Old session aborted.');
|
||||
}
|
||||
|
||||
if (start < this.items.length) {
|
||||
this.progress = start;
|
||||
await this.processItems(slicedItems);
|
||||
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
setTimeout(() => {
|
||||
this.loop({
|
||||
start: start + BUNDLE_SIZE,
|
||||
end: end + BUNDLE_SIZE,
|
||||
sessionId,
|
||||
})
|
||||
.then(() => resolve())
|
||||
.catch((e) => reject(new Error(e.message)));
|
||||
}, UPDATE_RATE)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private log(
|
||||
message: string,
|
||||
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
|
||||
optional?: Record<string, unknown>
|
||||
): void {
|
||||
logger[level](message, { label: 'Plex Sync', ...optional });
|
||||
}
|
||||
|
||||
// checks if any of this.libraries has Hama agent set in Plex
|
||||
private async hasHamaAgent() {
|
||||
const plexLibraries = await this.plexClient.getLibraries();
|
||||
return this.libraries.some((library) =>
|
||||
plexLibraries.some(
|
||||
(plexLibrary) =>
|
||||
plexLibrary.agent === HAMA_AGENT && library.id === plexLibrary.key
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const settings = getSettings();
|
||||
const sessionId = uuid();
|
||||
this.sessionId = sessionId;
|
||||
logger.info('Plex Sync Starting', { sessionId, label: 'Plex Sync' });
|
||||
try {
|
||||
this.running = true;
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOne({
|
||||
select: ['id', 'plexToken'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
return this.log('No admin configured. Plex sync skipped.', 'warn');
|
||||
}
|
||||
|
||||
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
|
||||
|
||||
this.libraries = settings.plex.libraries.filter(
|
||||
(library) => library.enabled
|
||||
);
|
||||
|
||||
this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k);
|
||||
if (this.enable4kMovie) {
|
||||
this.log(
|
||||
'At least one 4K Radarr server was detected. 4K movie detection is now enabled',
|
||||
'info'
|
||||
);
|
||||
}
|
||||
|
||||
this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k);
|
||||
if (this.enable4kShow) {
|
||||
this.log(
|
||||
'At least one 4K Sonarr server was detected. 4K series detection is now enabled',
|
||||
'info'
|
||||
);
|
||||
}
|
||||
|
||||
const hasHama = await this.hasHamaAgent();
|
||||
if (hasHama) {
|
||||
await animeList.sync();
|
||||
}
|
||||
|
||||
if (this.isRecentOnly) {
|
||||
for (const library of this.libraries) {
|
||||
this.currentLibrary = library;
|
||||
this.log(
|
||||
`Beginning to process recently added for library: ${library.name}`,
|
||||
'info'
|
||||
);
|
||||
const libraryItems = await this.plexClient.getRecentlyAdded(
|
||||
library.id
|
||||
);
|
||||
|
||||
// Bundle items up by rating keys
|
||||
this.items = uniqWith(libraryItems, (mediaA, mediaB) => {
|
||||
if (mediaA.grandparentRatingKey && mediaB.grandparentRatingKey) {
|
||||
return (
|
||||
mediaA.grandparentRatingKey === mediaB.grandparentRatingKey
|
||||
);
|
||||
}
|
||||
|
||||
if (mediaA.parentRatingKey && mediaB.parentRatingKey) {
|
||||
return mediaA.parentRatingKey === mediaB.parentRatingKey;
|
||||
}
|
||||
|
||||
return mediaA.ratingKey === mediaB.ratingKey;
|
||||
});
|
||||
|
||||
await this.loop({ sessionId });
|
||||
}
|
||||
} else {
|
||||
for (const library of this.libraries) {
|
||||
this.currentLibrary = library;
|
||||
this.log(`Beginning to process library: ${library.name}`, 'info');
|
||||
this.items = await this.plexClient.getLibraryContents(library.id);
|
||||
await this.loop({ sessionId });
|
||||
}
|
||||
}
|
||||
this.log(
|
||||
this.isRecentOnly
|
||||
? 'Recently Added Scan Complete'
|
||||
: 'Full Scan Complete'
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error('Sync interrupted', {
|
||||
label: 'Plex Sync',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
} finally {
|
||||
// If a new scanning session hasnt started, set running back to false
|
||||
if (this.sessionId === sessionId) {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public status(): SyncStatus {
|
||||
return {
|
||||
running: this.running,
|
||||
progress: this.progress,
|
||||
total: this.items.length,
|
||||
currentLibrary: this.currentLibrary,
|
||||
libraries: this.libraries,
|
||||
};
|
||||
}
|
||||
|
||||
public cancel(): void {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
export const jobPlexFullSync = new JobPlexSync();
|
||||
export const jobPlexRecentSync = new JobPlexSync({ isRecentOnly: true });
|
||||
@@ -1,31 +1,99 @@
|
||||
import schedule from 'node-schedule';
|
||||
import { jobPlexFullSync, jobPlexRecentSync } from './plexsync';
|
||||
import logger from '../logger';
|
||||
import downloadTracker from '../lib/downloadtracker';
|
||||
import { plexFullScanner, plexRecentScanner } from '../lib/scanners/plex';
|
||||
import { radarrScanner } from '../lib/scanners/radarr';
|
||||
import { sonarrScanner } from '../lib/scanners/sonarr';
|
||||
|
||||
interface ScheduledJob {
|
||||
id: string;
|
||||
job: schedule.Job;
|
||||
name: string;
|
||||
type: 'process' | 'command';
|
||||
running?: () => boolean;
|
||||
cancelFn?: () => void;
|
||||
}
|
||||
|
||||
export const scheduledJobs: ScheduledJob[] = [];
|
||||
|
||||
export const startJobs = (): void => {
|
||||
// Run recently added plex sync every 5 minutes
|
||||
// Run recently added plex scan every 5 minutes
|
||||
scheduledJobs.push({
|
||||
name: 'Plex Recently Added Sync',
|
||||
id: 'plex-recently-added-scan',
|
||||
name: 'Plex Recently Added Scan',
|
||||
type: 'process',
|
||||
job: schedule.scheduleJob('0 */5 * * * *', () => {
|
||||
logger.info('Starting scheduled job: Plex Recently Added Sync', {
|
||||
logger.info('Starting scheduled job: Plex Recently Added Scan', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
jobPlexRecentSync.run();
|
||||
plexRecentScanner.run();
|
||||
}),
|
||||
running: () => plexRecentScanner.status().running,
|
||||
cancelFn: () => plexRecentScanner.cancel(),
|
||||
});
|
||||
|
||||
// Run full plex scan every 24 hours
|
||||
scheduledJobs.push({
|
||||
id: 'plex-full-scan',
|
||||
name: 'Plex Full Library Scan',
|
||||
type: 'process',
|
||||
job: schedule.scheduleJob('0 0 3 * * *', () => {
|
||||
logger.info('Starting scheduled job: Plex Full Library Scan', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
plexFullScanner.run();
|
||||
}),
|
||||
running: () => plexFullScanner.status().running,
|
||||
cancelFn: () => plexFullScanner.cancel(),
|
||||
});
|
||||
|
||||
// Run full radarr scan every 24 hours
|
||||
scheduledJobs.push({
|
||||
id: 'radarr-scan',
|
||||
name: 'Radarr Scan',
|
||||
type: 'process',
|
||||
job: schedule.scheduleJob('0 0 4 * * *', () => {
|
||||
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
|
||||
radarrScanner.run();
|
||||
}),
|
||||
running: () => radarrScanner.status().running,
|
||||
cancelFn: () => radarrScanner.cancel(),
|
||||
});
|
||||
|
||||
// Run full sonarr scan every 24 hours
|
||||
scheduledJobs.push({
|
||||
id: 'sonarr-scan',
|
||||
name: 'Sonarr Scan',
|
||||
type: 'process',
|
||||
job: schedule.scheduleJob('0 30 4 * * *', () => {
|
||||
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
|
||||
sonarrScanner.run();
|
||||
}),
|
||||
running: () => sonarrScanner.status().running,
|
||||
cancelFn: () => sonarrScanner.cancel(),
|
||||
});
|
||||
|
||||
// Run download sync
|
||||
scheduledJobs.push({
|
||||
id: 'download-sync',
|
||||
name: 'Download Sync',
|
||||
type: 'command',
|
||||
job: schedule.scheduleJob('0 * * * * *', () => {
|
||||
logger.debug('Starting scheduled job: Download Sync', { label: 'Jobs' });
|
||||
downloadTracker.updateDownloads();
|
||||
}),
|
||||
});
|
||||
// Run full plex sync every 24 hours
|
||||
|
||||
// Reset download sync
|
||||
scheduledJobs.push({
|
||||
name: 'Plex Full Library Sync',
|
||||
job: schedule.scheduleJob('0 0 3 * * *', () => {
|
||||
logger.info('Starting scheduled job: Plex Full Sync', { label: 'Jobs' });
|
||||
jobPlexFullSync.run();
|
||||
id: 'download-sync-reset',
|
||||
name: 'Download Sync Reset',
|
||||
type: 'command',
|
||||
job: schedule.scheduleJob('0 0 1 * * *', () => {
|
||||
logger.info('Starting scheduled job: Download Sync Reset', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
downloadTracker.resetDownloadTracker();
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
60
server/lib/cache.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import NodeCache from 'node-cache';
|
||||
|
||||
export type AvailableCacheIds = 'tmdb' | 'radarr' | 'sonarr' | 'rt';
|
||||
|
||||
const DEFAULT_TTL = 300;
|
||||
const DEFAULT_CHECK_PERIOD = 120;
|
||||
|
||||
class Cache {
|
||||
public id: AvailableCacheIds;
|
||||
public data: NodeCache;
|
||||
public name: string;
|
||||
|
||||
constructor(
|
||||
id: AvailableCacheIds,
|
||||
name: string,
|
||||
options: { stdTtl?: number; checkPeriod?: number } = {}
|
||||
) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.data = new NodeCache({
|
||||
stdTTL: options.stdTtl ?? DEFAULT_TTL,
|
||||
checkperiod: options.checkPeriod ?? DEFAULT_CHECK_PERIOD,
|
||||
});
|
||||
}
|
||||
|
||||
public getStats() {
|
||||
return this.data.getStats();
|
||||
}
|
||||
|
||||
public flush(): void {
|
||||
this.data.flushAll();
|
||||
}
|
||||
}
|
||||
|
||||
class CacheManager {
|
||||
private availableCaches: Record<AvailableCacheIds, Cache> = {
|
||||
tmdb: new Cache('tmdb', 'TMDb API', {
|
||||
stdTtl: 21600,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
radarr: new Cache('radarr', 'Radarr API'),
|
||||
sonarr: new Cache('sonarr', 'Sonarr API'),
|
||||
rt: new Cache('rt', 'Rotten Tomatoes API', {
|
||||
stdTtl: 43200,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
};
|
||||
|
||||
public getCache(id: AvailableCacheIds): Cache {
|
||||
return this.availableCaches[id];
|
||||
}
|
||||
|
||||
public getAllCaches(): Record<string, Cache> {
|
||||
return this.availableCaches;
|
||||
}
|
||||
}
|
||||
|
||||
const cacheManager = new CacheManager();
|
||||
|
||||
export default cacheManager;
|
||||
195
server/lib/downloadtracker.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { uniqWith } from 'lodash';
|
||||
import RadarrAPI from '../api/radarr';
|
||||
import SonarrAPI from '../api/sonarr';
|
||||
import { MediaType } from '../constants/media';
|
||||
import logger from '../logger';
|
||||
import { getSettings } from './settings';
|
||||
|
||||
export interface DownloadingItem {
|
||||
mediaType: MediaType;
|
||||
externalId: number;
|
||||
size: number;
|
||||
sizeLeft: number;
|
||||
status: string;
|
||||
timeLeft: string;
|
||||
estimatedCompletionTime: Date;
|
||||
title: string;
|
||||
}
|
||||
|
||||
class DownloadTracker {
|
||||
private radarrServers: Record<number, DownloadingItem[]> = {};
|
||||
private sonarrServers: Record<number, DownloadingItem[]> = {};
|
||||
|
||||
public getMovieProgress(
|
||||
serverId: number,
|
||||
externalServiceId: number
|
||||
): DownloadingItem[] {
|
||||
if (!this.radarrServers[serverId]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.radarrServers[serverId].filter(
|
||||
(item) => item.externalId === externalServiceId
|
||||
);
|
||||
}
|
||||
|
||||
public getSeriesProgress(
|
||||
serverId: number,
|
||||
externalServiceId: number
|
||||
): DownloadingItem[] {
|
||||
if (!this.sonarrServers[serverId]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.sonarrServers[serverId].filter(
|
||||
(item) => item.externalId === externalServiceId
|
||||
);
|
||||
}
|
||||
|
||||
public async resetDownloadTracker() {
|
||||
this.radarrServers = {};
|
||||
}
|
||||
|
||||
public updateDownloads() {
|
||||
this.updateRadarrDownloads();
|
||||
this.updateSonarrDownloads();
|
||||
}
|
||||
|
||||
private async updateRadarrDownloads() {
|
||||
const settings = getSettings();
|
||||
|
||||
// Remove duplicate servers
|
||||
const filteredServers = uniqWith(settings.radarr, (radarrA, radarrB) => {
|
||||
return (
|
||||
radarrA.hostname === radarrB.hostname &&
|
||||
radarrA.port === radarrB.port &&
|
||||
radarrA.baseUrl === radarrB.baseUrl
|
||||
);
|
||||
});
|
||||
|
||||
// Load downloads from Radarr servers
|
||||
Promise.all(
|
||||
filteredServers.map(async (server) => {
|
||||
if (server.syncEnabled) {
|
||||
const radarr = new RadarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: RadarrAPI.buildRadarrUrl(server, '/api/v3'),
|
||||
});
|
||||
|
||||
const queueItems = await radarr.getQueue();
|
||||
|
||||
this.radarrServers[server.id] = queueItems.map((item) => ({
|
||||
externalId: item.movieId,
|
||||
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
|
||||
mediaType: MediaType.MOVIE,
|
||||
size: item.size,
|
||||
sizeLeft: item.sizeleft,
|
||||
status: item.status,
|
||||
timeLeft: item.timeleft,
|
||||
title: item.title,
|
||||
}));
|
||||
|
||||
if (queueItems.length > 0) {
|
||||
logger.debug(
|
||||
`Found ${queueItems.length} item(s) in progress on Radarr server: ${server.name}`,
|
||||
{ label: 'Download Tracker' }
|
||||
);
|
||||
}
|
||||
|
||||
// Duplicate this data to matching servers
|
||||
const matchingServers = settings.radarr.filter(
|
||||
(rs) =>
|
||||
rs.hostname === server.hostname &&
|
||||
rs.port === server.port &&
|
||||
rs.baseUrl === server.baseUrl &&
|
||||
rs.id !== server.id
|
||||
);
|
||||
|
||||
if (matchingServers.length > 0) {
|
||||
logger.debug(
|
||||
`Matching download data to ${matchingServers.length} other Radarr server(s)`,
|
||||
{ label: 'Download Tracker' }
|
||||
);
|
||||
}
|
||||
|
||||
matchingServers.forEach((ms) => {
|
||||
if (ms.syncEnabled) {
|
||||
this.radarrServers[ms.id] = this.radarrServers[server.id];
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async updateSonarrDownloads() {
|
||||
const settings = getSettings();
|
||||
|
||||
// Remove duplicate servers
|
||||
const filteredServers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => {
|
||||
return (
|
||||
sonarrA.hostname === sonarrB.hostname &&
|
||||
sonarrA.port === sonarrB.port &&
|
||||
sonarrA.baseUrl === sonarrB.baseUrl
|
||||
);
|
||||
});
|
||||
|
||||
// Load downloads from Radarr servers
|
||||
Promise.all(
|
||||
filteredServers.map(async (server) => {
|
||||
if (server.syncEnabled) {
|
||||
const radarr = new SonarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: SonarrAPI.buildSonarrUrl(server, '/api/v3'),
|
||||
});
|
||||
|
||||
const queueItems = await radarr.getQueue();
|
||||
|
||||
this.sonarrServers[server.id] = queueItems.map((item) => ({
|
||||
externalId: item.seriesId,
|
||||
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
|
||||
mediaType: MediaType.TV,
|
||||
size: item.size,
|
||||
sizeLeft: item.sizeleft,
|
||||
status: item.status,
|
||||
timeLeft: item.timeleft,
|
||||
title: item.title,
|
||||
}));
|
||||
|
||||
if (queueItems.length > 0) {
|
||||
logger.debug(
|
||||
`Found ${queueItems.length} item(s) in progress on Sonarr server: ${server.name}`,
|
||||
{ label: 'Download Tracker' }
|
||||
);
|
||||
}
|
||||
|
||||
// Duplicate this data to matching servers
|
||||
const matchingServers = settings.sonarr.filter(
|
||||
(rs) =>
|
||||
rs.hostname === server.hostname &&
|
||||
rs.port === server.port &&
|
||||
rs.baseUrl === server.baseUrl &&
|
||||
rs.id !== server.id
|
||||
);
|
||||
|
||||
if (matchingServers.length > 0) {
|
||||
logger.debug(
|
||||
`Matching download data to ${matchingServers.length} other Sonarr server(s)`,
|
||||
{ label: 'Download Tracker' }
|
||||
);
|
||||
}
|
||||
|
||||
matchingServers.forEach((ms) => {
|
||||
if (ms.syncEnabled) {
|
||||
this.sonarrServers[ms.id] = this.sonarrServers[server.id];
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const downloadTracker = new DownloadTracker();
|
||||
|
||||
export default downloadTracker;
|
||||
@@ -1,8 +1,9 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import Email from 'email-templates';
|
||||
import { getSettings } from '../settings';
|
||||
import { openpgpEncrypt } from './openpgpEncrypt';
|
||||
class PreparedEmail extends Email {
|
||||
public constructor() {
|
||||
public constructor(pgpKey?: string) {
|
||||
const settings = getSettings().notifications.agents.email;
|
||||
|
||||
const transport = nodemailer.createTransport({
|
||||
@@ -22,6 +23,16 @@ class PreparedEmail extends Email {
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
if (pgpKey) {
|
||||
transport.use(
|
||||
'stream',
|
||||
openpgpEncrypt({
|
||||
signingKey: settings.options.pgpPrivateKey,
|
||||
password: settings.options.pgpPassword,
|
||||
encryptionKeys: [pgpKey],
|
||||
})
|
||||
);
|
||||
}
|
||||
super({
|
||||
message: {
|
||||
from: {
|
||||
|
||||
181
server/lib/email/openpgpEncrypt.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import * as openpgp from 'openpgp';
|
||||
import { Transform, TransformCallback } from 'stream';
|
||||
import crypto from 'crypto';
|
||||
|
||||
interface EncryptorOptions {
|
||||
signingKey?: string;
|
||||
password?: string;
|
||||
encryptionKeys: string[];
|
||||
}
|
||||
|
||||
class PGPEncryptor extends Transform {
|
||||
private _messageChunks: Uint8Array[] = [];
|
||||
private _messageLength = 0;
|
||||
private _signingKey?: string;
|
||||
private _password?: string;
|
||||
|
||||
private _encryptionKeys: string[];
|
||||
|
||||
constructor(options: EncryptorOptions) {
|
||||
super();
|
||||
this._signingKey = options.signingKey;
|
||||
this._password = options.password;
|
||||
this._encryptionKeys = options.encryptionKeys;
|
||||
}
|
||||
|
||||
// just save the whole message
|
||||
_transform = (
|
||||
chunk: any,
|
||||
_encoding: BufferEncoding,
|
||||
callback: TransformCallback
|
||||
): void => {
|
||||
this._messageChunks.push(chunk);
|
||||
this._messageLength += chunk.length;
|
||||
callback();
|
||||
};
|
||||
|
||||
// Actually do stuff
|
||||
_flush = async (callback: TransformCallback): Promise<void> => {
|
||||
// Reconstruct message as buffer
|
||||
const message = Buffer.concat(this._messageChunks, this._messageLength);
|
||||
const validPublicKeys = await Promise.all(
|
||||
this._encryptionKeys.map((armoredKey) => openpgp.readKey({ armoredKey }))
|
||||
);
|
||||
let privateKey: openpgp.Key | undefined;
|
||||
|
||||
// Just return the message if there is no one to encrypt for
|
||||
if (!validPublicKeys.length) {
|
||||
this.push(message);
|
||||
return callback();
|
||||
}
|
||||
|
||||
// Only sign the message if private key and password exist
|
||||
if (this._signingKey && this._password) {
|
||||
privateKey = await openpgp.readKey({
|
||||
armoredKey: this._signingKey,
|
||||
});
|
||||
await privateKey.decrypt(this._password);
|
||||
}
|
||||
|
||||
const emailPartDelimiter = '\r\n\r\n';
|
||||
const messageParts = message.toString().split(emailPartDelimiter);
|
||||
|
||||
/**
|
||||
* In this loop original headers are split up into two parts,
|
||||
* one for the email that is sent
|
||||
* and one for the encrypted content
|
||||
*/
|
||||
const header = messageParts.shift() as string;
|
||||
const emailHeaders: string[][] = [];
|
||||
const contentHeaders: string[][] = [];
|
||||
const linesInHeader = header.split('\r\n');
|
||||
let previousHeader: string[] = [];
|
||||
for (let i = 0; i < linesInHeader.length; i++) {
|
||||
const line = linesInHeader[i];
|
||||
/**
|
||||
* If it is a multi-line header (current line starts with whitespace)
|
||||
* or it's the first line in the iteration
|
||||
* add the current line with previous header and move on
|
||||
*/
|
||||
if (/^\s/.test(line) || i === 0) {
|
||||
previousHeader.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is done to prevent the last header
|
||||
* from being missed
|
||||
*/
|
||||
if (i === linesInHeader.length - 1) {
|
||||
previousHeader.push(line);
|
||||
}
|
||||
|
||||
/**
|
||||
* We need to seperate the actual content headers
|
||||
* so that we can add it as a header for the encrypted content
|
||||
* So that the content will be displayed properly after decryption
|
||||
*/
|
||||
if (
|
||||
/^(content-type|content-transfer-encoding):/i.test(previousHeader[0])
|
||||
) {
|
||||
contentHeaders.push(previousHeader);
|
||||
} else {
|
||||
emailHeaders.push(previousHeader);
|
||||
}
|
||||
previousHeader = [line];
|
||||
}
|
||||
|
||||
// Generate a new boundary for the email content
|
||||
const boundary = 'nm_' + crypto.randomBytes(14).toString('hex');
|
||||
/**
|
||||
* Concatenate everything into single strings
|
||||
* and add pgp headers to the email headers
|
||||
*/
|
||||
const emailHeadersRaw =
|
||||
emailHeaders.map((line) => line.join('\r\n')).join('\r\n') +
|
||||
'\r\n' +
|
||||
'Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";' +
|
||||
'\r\n' +
|
||||
' boundary="' +
|
||||
boundary +
|
||||
'"' +
|
||||
'\r\n' +
|
||||
'Content-Description: OpenPGP encrypted message' +
|
||||
'\r\n' +
|
||||
'Content-Transfer-Encoding: 7bit';
|
||||
const contentHeadersRaw = contentHeaders
|
||||
.map((line) => line.join('\r\n'))
|
||||
.join('\r\n');
|
||||
|
||||
const encryptedMessage = await openpgp.encrypt({
|
||||
message: openpgp.Message.fromText(
|
||||
contentHeadersRaw +
|
||||
emailPartDelimiter +
|
||||
messageParts.join(emailPartDelimiter)
|
||||
),
|
||||
publicKeys: validPublicKeys,
|
||||
privateKeys: privateKey,
|
||||
});
|
||||
|
||||
const body =
|
||||
'--' +
|
||||
boundary +
|
||||
'\r\n' +
|
||||
'Content-Type: application/pgp-encrypted\r\n' +
|
||||
'Content-Transfer-Encoding: 7bit\r\n' +
|
||||
'\r\n' +
|
||||
'Version: 1\r\n' +
|
||||
'\r\n' +
|
||||
'--' +
|
||||
boundary +
|
||||
'\r\n' +
|
||||
'Content-Type: application/octet-stream; name=encrypted.asc\r\n' +
|
||||
'Content-Disposition: inline; filename=encrypted.asc\r\n' +
|
||||
'Content-Transfer-Encoding: 7bit\r\n' +
|
||||
'\r\n' +
|
||||
encryptedMessage +
|
||||
'\r\n--' +
|
||||
boundary +
|
||||
'--\r\n';
|
||||
|
||||
this.push(Buffer.from(emailHeadersRaw + emailPartDelimiter + body));
|
||||
callback();
|
||||
};
|
||||
}
|
||||
|
||||
export const openpgpEncrypt = (options: EncryptorOptions) => {
|
||||
return function (mail: any, callback: () => unknown): void {
|
||||
if (!options.encryptionKeys.length) {
|
||||
setImmediate(callback);
|
||||
}
|
||||
mail.message.transform(
|
||||
() =>
|
||||
new PGPEncryptor({
|
||||
signingKey: options.signingKey,
|
||||
password: options.password,
|
||||
encryptionKeys: options.encryptionKeys,
|
||||
})
|
||||
);
|
||||
setImmediate(callback);
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Notification } from '..';
|
||||
import Media from '../../../entity/Media';
|
||||
import { MediaRequest } from '../../../entity/MediaRequest';
|
||||
import { User } from '../../../entity/User';
|
||||
import { NotificationAgentConfig } from '../../settings';
|
||||
|
||||
@@ -10,6 +11,7 @@ export interface NotificationPayload {
|
||||
image?: string;
|
||||
message?: string;
|
||||
extra?: { name: string; value: string }[];
|
||||
request?: MediaRequest;
|
||||
}
|
||||
|
||||
export abstract class BaseAgent<T extends NotificationAgentConfig> {
|
||||
@@ -19,9 +21,15 @@ export abstract class BaseAgent<T extends NotificationAgentConfig> {
|
||||
}
|
||||
|
||||
protected abstract getSettings(): T;
|
||||
|
||||
protected userNotificationTypes: Notification[] = [
|
||||
Notification.MEDIA_APPROVED,
|
||||
Notification.MEDIA_DECLINED,
|
||||
Notification.MEDIA_AVAILABLE,
|
||||
];
|
||||
}
|
||||
|
||||
export interface NotificationAgent {
|
||||
shouldSend(type: Notification): boolean;
|
||||
shouldSend(type: Notification, payload: NotificationPayload): boolean;
|
||||
send(type: Notification, payload: NotificationPayload): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -71,9 +71,15 @@ interface DiscordRichEmbed {
|
||||
|
||||
interface DiscordWebhookPayload {
|
||||
embeds: DiscordRichEmbed[];
|
||||
username: string;
|
||||
username?: string;
|
||||
avatar_url?: string;
|
||||
tts: boolean;
|
||||
content?: string;
|
||||
allowed_mentions?: {
|
||||
parse?: ('users' | 'roles' | 'everyone')[];
|
||||
roles?: string[];
|
||||
users?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
class DiscordAgent
|
||||
@@ -98,112 +104,73 @@ class DiscordAgent
|
||||
|
||||
const fields: Field[] = [];
|
||||
|
||||
if (payload.request) {
|
||||
fields.push({
|
||||
name: 'Requested By',
|
||||
value: payload.notifyUser.displayName ?? '',
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
color = EmbedColors.ORANGE;
|
||||
fields.push(
|
||||
{
|
||||
name: 'Requested By',
|
||||
value: payload.notifyUser.username ?? '',
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: 'Status',
|
||||
value: 'Pending Approval',
|
||||
inline: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (settings.main.applicationUrl) {
|
||||
fields.push({
|
||||
name: 'View Media',
|
||||
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||
});
|
||||
}
|
||||
fields.push({
|
||||
name: 'Status',
|
||||
value: 'Pending Approval',
|
||||
inline: true,
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
color = EmbedColors.PURPLE;
|
||||
fields.push(
|
||||
{
|
||||
name: 'Requested By',
|
||||
value: payload.notifyUser.username ?? '',
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: 'Status',
|
||||
value: 'Processing Request',
|
||||
inline: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (settings.main.applicationUrl) {
|
||||
fields.push({
|
||||
name: 'View Media',
|
||||
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||
});
|
||||
}
|
||||
fields.push({
|
||||
name: 'Status',
|
||||
value: 'Processing',
|
||||
inline: true,
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
color = EmbedColors.GREEN;
|
||||
fields.push(
|
||||
{
|
||||
name: 'Requested By',
|
||||
value: payload.notifyUser.username ?? '',
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: 'Status',
|
||||
value: 'Available',
|
||||
inline: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (settings.main.applicationUrl) {
|
||||
fields.push({
|
||||
name: 'View Media',
|
||||
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||
});
|
||||
}
|
||||
fields.push({
|
||||
name: 'Status',
|
||||
value: 'Available',
|
||||
inline: true,
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
color = EmbedColors.RED;
|
||||
fields.push(
|
||||
{
|
||||
name: 'Requested By',
|
||||
value: payload.notifyUser.username ?? '',
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: 'Status',
|
||||
value: 'Declined',
|
||||
inline: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (settings.main.applicationUrl) {
|
||||
fields.push({
|
||||
name: 'View Media',
|
||||
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||
});
|
||||
}
|
||||
fields.push({
|
||||
name: 'Status',
|
||||
value: 'Declined',
|
||||
inline: true,
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
color = EmbedColors.RED;
|
||||
if (settings.main.applicationUrl) {
|
||||
fields.push({
|
||||
name: 'View Media',
|
||||
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||
});
|
||||
}
|
||||
fields.push({
|
||||
name: 'Status',
|
||||
value: 'Failed',
|
||||
inline: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const url =
|
||||
settings.main.applicationUrl && payload.media
|
||||
? `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
title: payload.subject,
|
||||
url,
|
||||
description: payload.message,
|
||||
color,
|
||||
timestamp: new Date().toISOString(),
|
||||
author: { name: 'Overseerr', url: settings.main.applicationUrl },
|
||||
author: {
|
||||
name: settings.main.applicationTitle,
|
||||
url: settings.main.applicationUrl,
|
||||
},
|
||||
fields: [
|
||||
...fields,
|
||||
// If we have extra data, map it to fields for discord notifications
|
||||
@@ -234,17 +201,38 @@ class DiscordAgent
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
logger.debug('Sending discord notification', { label: 'Notifications' });
|
||||
logger.debug('Sending Discord notification', { label: 'Notifications' });
|
||||
try {
|
||||
const webhookUrl = this.getSettings().options.webhookUrl;
|
||||
const {
|
||||
botUsername,
|
||||
botAvatarUrl,
|
||||
webhookUrl,
|
||||
} = this.getSettings().options;
|
||||
|
||||
if (!webhookUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mentionedUsers: string[] = [];
|
||||
let content = undefined;
|
||||
|
||||
if (
|
||||
this.userNotificationTypes.includes(type) &&
|
||||
payload.notifyUser.settings?.enableNotifications &&
|
||||
payload.notifyUser.settings?.discordId
|
||||
) {
|
||||
mentionedUsers.push(payload.notifyUser.settings.discordId);
|
||||
content = `<@${payload.notifyUser.settings.discordId}>`;
|
||||
}
|
||||
|
||||
await axios.post(webhookUrl, {
|
||||
username: 'Overseerr',
|
||||
username: botUsername,
|
||||
avatar_url: botAvatarUrl,
|
||||
embeds: [this.buildEmbed(type, payload)],
|
||||
content,
|
||||
allowed_mentions: {
|
||||
users: mentionedUsers,
|
||||
},
|
||||
} as DiscordWebhookPayload);
|
||||
|
||||
return true;
|
||||
@@ -252,6 +240,7 @@ class DiscordAgent
|
||||
logger.error('Error sending Discord notification', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
response: e.response.data,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getRepository } from 'typeorm';
|
||||
import { User } from '../../../entity/User';
|
||||
import { Permission } from '../../permissions';
|
||||
import PreparedEmail from '../../email';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
|
||||
class EmailAgent
|
||||
extends BaseAgent<NotificationAgentEmail>
|
||||
@@ -21,12 +22,13 @@ class EmailAgent
|
||||
return settings.notifications.agents.email;
|
||||
}
|
||||
|
||||
public shouldSend(type: Notification): boolean {
|
||||
public shouldSend(type: Notification, payload: NotificationPayload): boolean {
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (
|
||||
settings.enabled &&
|
||||
hasNotificationType(type, this.getSettings().types)
|
||||
hasNotificationType(type, this.getSettings().types) &&
|
||||
(payload.notifyUser.settings?.enableNotifications ?? true)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -36,7 +38,7 @@ class EmailAgent
|
||||
|
||||
private async sendMediaRequestEmail(payload: NotificationPayload) {
|
||||
// This is getting main settings for the whole app
|
||||
const applicationUrl = getSettings().main.applicationUrl;
|
||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||
try {
|
||||
const userRepository = getRepository(User);
|
||||
const users = await userRepository.find();
|
||||
@@ -45,7 +47,7 @@ class EmailAgent
|
||||
users
|
||||
.filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS))
|
||||
.forEach((user) => {
|
||||
const email = new PreparedEmail();
|
||||
const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey);
|
||||
|
||||
email.send({
|
||||
template: path.join(
|
||||
@@ -56,22 +58,27 @@ class EmailAgent
|
||||
to: user.email,
|
||||
},
|
||||
locals: {
|
||||
body: 'A user has requested new media!',
|
||||
body: `A user has requested a new ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
}!`,
|
||||
mediaName: payload.subject,
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
requestedBy: payload.notifyUser.username,
|
||||
requestedBy: payload.notifyUser.displayName,
|
||||
actionUrl: applicationUrl
|
||||
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
|
||||
: undefined,
|
||||
applicationUrl,
|
||||
requestType: 'New Request',
|
||||
applicationTitle,
|
||||
requestType: `New ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`,
|
||||
},
|
||||
});
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Mail notification failed to send', {
|
||||
logger.error('Email notification failed to send', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
@@ -81,7 +88,101 @@ class EmailAgent
|
||||
|
||||
private async sendMediaFailedEmail(payload: NotificationPayload) {
|
||||
// This is getting main settings for the whole app
|
||||
const applicationUrl = getSettings().main.applicationUrl;
|
||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||
try {
|
||||
const userRepository = getRepository(User);
|
||||
const users = await userRepository.find();
|
||||
|
||||
// Send to all users with the manage requests permission (or admins)
|
||||
users
|
||||
.filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS))
|
||||
.forEach((user) => {
|
||||
const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey);
|
||||
|
||||
email.send({
|
||||
template: path.join(
|
||||
__dirname,
|
||||
'../../../templates/email/media-request'
|
||||
),
|
||||
message: {
|
||||
to: user.email,
|
||||
},
|
||||
locals: {
|
||||
body: `A new request for the following ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} could not be added to ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Sonarr' : 'Radarr'
|
||||
}`,
|
||||
mediaName: payload.subject,
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
requestedBy: payload.notifyUser.displayName,
|
||||
actionUrl: applicationUrl
|
||||
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
|
||||
: undefined,
|
||||
applicationUrl,
|
||||
applicationTitle,
|
||||
requestType: `Failed ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`,
|
||||
},
|
||||
});
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Email notification failed to send', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendMediaApprovedEmail(payload: NotificationPayload) {
|
||||
// This is getting main settings for the whole app
|
||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||
try {
|
||||
const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey);
|
||||
|
||||
await email.send({
|
||||
template: path.join(
|
||||
__dirname,
|
||||
'../../../templates/email/media-request'
|
||||
),
|
||||
message: {
|
||||
to: payload.notifyUser.email,
|
||||
},
|
||||
locals: {
|
||||
body: `Your request for the following ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} has been approved:`,
|
||||
mediaName: payload.subject,
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
requestedBy: payload.notifyUser.displayName,
|
||||
actionUrl: applicationUrl
|
||||
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
|
||||
: undefined,
|
||||
applicationUrl,
|
||||
applicationTitle,
|
||||
requestType: `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Approved`,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Email notification failed to send', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendMediaAutoApprovedEmail(payload: NotificationPayload) {
|
||||
// This is getting main settings for the whole app
|
||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||
try {
|
||||
const userRepository = getRepository(User);
|
||||
const users = await userRepository.find();
|
||||
@@ -101,60 +202,27 @@ class EmailAgent
|
||||
to: user.email,
|
||||
},
|
||||
locals: {
|
||||
body:
|
||||
"A user's new request has failed to add to Sonarr or Radarr",
|
||||
body: `A new request for the following ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} has been automatically approved:`,
|
||||
mediaName: payload.subject,
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
requestedBy: payload.notifyUser.username,
|
||||
requestedBy: payload.notifyUser.displayName,
|
||||
actionUrl: applicationUrl
|
||||
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
|
||||
: undefined,
|
||||
applicationUrl,
|
||||
requestType: 'Failed Request',
|
||||
applicationTitle,
|
||||
requestType: `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Automatically Approved`,
|
||||
},
|
||||
});
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Mail notification failed to send', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendMediaApprovedEmail(payload: NotificationPayload) {
|
||||
// This is getting main settings for the whole app
|
||||
const applicationUrl = getSettings().main.applicationUrl;
|
||||
try {
|
||||
const email = new PreparedEmail();
|
||||
|
||||
await email.send({
|
||||
template: path.join(
|
||||
__dirname,
|
||||
'../../../templates/email/media-request'
|
||||
),
|
||||
message: {
|
||||
to: payload.notifyUser.email,
|
||||
},
|
||||
locals: {
|
||||
body: 'Your request for the following media has been approved:',
|
||||
mediaName: payload.subject,
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
requestedBy: payload.notifyUser.username,
|
||||
actionUrl: applicationUrl
|
||||
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
|
||||
: undefined,
|
||||
applicationUrl,
|
||||
requestType: 'Request Approved',
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Mail notification failed to send', {
|
||||
logger.error('Email notification failed to send', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
@@ -164,9 +232,9 @@ class EmailAgent
|
||||
|
||||
private async sendMediaDeclinedEmail(payload: NotificationPayload) {
|
||||
// This is getting main settings for the whole app
|
||||
const applicationUrl = getSettings().main.applicationUrl;
|
||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||
try {
|
||||
const email = new PreparedEmail();
|
||||
const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey);
|
||||
|
||||
await email.send({
|
||||
template: path.join(
|
||||
@@ -177,21 +245,26 @@ class EmailAgent
|
||||
to: payload.notifyUser.email,
|
||||
},
|
||||
locals: {
|
||||
body: 'Your request for the following media was declined:',
|
||||
body: `Your request for the following ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} was declined:`,
|
||||
mediaName: payload.subject,
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
requestedBy: payload.notifyUser.username,
|
||||
requestedBy: payload.notifyUser.displayName,
|
||||
actionUrl: applicationUrl
|
||||
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
|
||||
: undefined,
|
||||
applicationUrl,
|
||||
requestType: 'Request Declined',
|
||||
applicationTitle,
|
||||
requestType: `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Declined`,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Mail notification failed to send', {
|
||||
logger.error('Email notification failed to send', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
@@ -201,9 +274,9 @@ class EmailAgent
|
||||
|
||||
private async sendMediaAvailableEmail(payload: NotificationPayload) {
|
||||
// This is getting main settings for the whole app
|
||||
const applicationUrl = getSettings().main.applicationUrl;
|
||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||
try {
|
||||
const email = new PreparedEmail();
|
||||
const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey);
|
||||
|
||||
await email.send({
|
||||
template: path.join(
|
||||
@@ -214,21 +287,26 @@ class EmailAgent
|
||||
to: payload.notifyUser.email,
|
||||
},
|
||||
locals: {
|
||||
body: 'Your requested media is now available!',
|
||||
body: `The following ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} you requested is now available!`,
|
||||
mediaName: payload.subject,
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
requestedBy: payload.notifyUser.username,
|
||||
requestedBy: payload.notifyUser.displayName,
|
||||
actionUrl: applicationUrl
|
||||
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
|
||||
: undefined,
|
||||
applicationUrl,
|
||||
requestType: 'Now Available',
|
||||
applicationTitle,
|
||||
requestType: `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Now Available`,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Mail notification failed to send', {
|
||||
logger.error('Email notification failed to send', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
@@ -238,9 +316,9 @@ class EmailAgent
|
||||
|
||||
private async sendTestEmail(payload: NotificationPayload) {
|
||||
// This is getting main settings for the whole app
|
||||
const applicationUrl = getSettings().main.applicationUrl;
|
||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||
try {
|
||||
const email = new PreparedEmail();
|
||||
const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey);
|
||||
|
||||
await email.send({
|
||||
template: path.join(__dirname, '../../../templates/email/test-email'),
|
||||
@@ -250,11 +328,12 @@ class EmailAgent
|
||||
locals: {
|
||||
body: payload.message,
|
||||
applicationUrl,
|
||||
applicationTitle,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Mail notification failed to send', {
|
||||
logger.error('Email notification failed to send', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
@@ -275,6 +354,9 @@ class EmailAgent
|
||||
case Notification.MEDIA_APPROVED:
|
||||
this.sendMediaApprovedEmail(payload);
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
this.sendMediaAutoApprovedEmail(payload);
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
this.sendMediaDeclinedEmail(payload);
|
||||
break;
|
||||
|
||||
168
server/lib/notifications/agents/pushbullet.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentPushbullet } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
|
||||
interface PushbulletPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
class PushbulletAgent
|
||||
extends BaseAgent<NotificationAgentPushbullet>
|
||||
implements NotificationAgent {
|
||||
protected getSettings(): NotificationAgentPushbullet {
|
||||
if (this.settings) {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
|
||||
return settings.notifications.agents.pushbullet;
|
||||
}
|
||||
|
||||
public shouldSend(type: Notification): boolean {
|
||||
if (
|
||||
this.getSettings().enabled &&
|
||||
this.getSettings().options.accessToken &&
|
||||
hasNotificationType(type, this.getSettings().types)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private constructMessageDetails(
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): {
|
||||
title: string;
|
||||
body: string;
|
||||
} {
|
||||
let messageTitle = '';
|
||||
let message = '';
|
||||
|
||||
const title = payload.subject;
|
||||
const plot = payload.message;
|
||||
const username = payload.notifyUser.displayName;
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
messageTitle = `New ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Pending Approval`;
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Approved`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Processing`;
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Automatically Approved`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Processing`;
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Now Available`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Available`;
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Declined`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Declined`;
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
messageTitle = `Failed ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Failed`;
|
||||
break;
|
||||
case Notification.TEST_NOTIFICATION:
|
||||
messageTitle = 'Test Notification';
|
||||
message += `${plot}`;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
title: messageTitle,
|
||||
body: message,
|
||||
};
|
||||
}
|
||||
|
||||
public async send(
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
logger.debug('Sending Pushbullet notification', { label: 'Notifications' });
|
||||
try {
|
||||
const endpoint = 'https://api.pushbullet.com/v2/pushes';
|
||||
|
||||
const { accessToken } = this.getSettings().options;
|
||||
|
||||
const { title, body } = this.constructMessageDetails(type, payload);
|
||||
|
||||
await axios.post(
|
||||
endpoint,
|
||||
{
|
||||
type: 'note',
|
||||
title: title,
|
||||
body: body,
|
||||
} as PushbulletPayload,
|
||||
{
|
||||
headers: {
|
||||
'Access-Token': accessToken,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Error sending Pushbullet notification', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PushbulletAgent;
|
||||
@@ -3,12 +3,16 @@ import { hasNotificationType, Notification } from '..';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentPushover } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
|
||||
interface PushoverPayload {
|
||||
token: string;
|
||||
user: string;
|
||||
title: string;
|
||||
message: string;
|
||||
url: string;
|
||||
url_title: string;
|
||||
priority: number;
|
||||
html: number;
|
||||
}
|
||||
|
||||
@@ -41,58 +45,111 @@ class PushoverAgent
|
||||
private constructMessageDetails(
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): { title: string; message: string } {
|
||||
): {
|
||||
title: string;
|
||||
message: string;
|
||||
url: string | undefined;
|
||||
url_title: string | undefined;
|
||||
priority: number;
|
||||
} {
|
||||
const settings = getSettings();
|
||||
let messageTitle = '';
|
||||
let message = '';
|
||||
let url: string | undefined;
|
||||
let url_title: string | undefined;
|
||||
let priority = 0;
|
||||
|
||||
const title = payload.subject;
|
||||
const plot = payload.message;
|
||||
const user = payload.notifyUser.username;
|
||||
const username = payload.notifyUser.displayName;
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
messageTitle = 'New Request';
|
||||
message += `${title}\n\n`;
|
||||
message += `${plot}\n\n`;
|
||||
message += `<b>Requested By</b>\n${user}\n\n`;
|
||||
message += `<b>Status</b>\nPending Approval\n`;
|
||||
messageTitle = `New ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n<b>Requested By</b>\n${username}`;
|
||||
message += `\n\n<b>Status</b>\nPending Approval`;
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
messageTitle = 'Request Approved';
|
||||
message += `${title}\n\n`;
|
||||
message += `${plot}\n\n`;
|
||||
message += `<b>Requested By</b>\n${user}\n\n`;
|
||||
message += `<b>Status</b>\nProcessing Request\n`;
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Approved`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n<b>Requested By</b>\n${username}`;
|
||||
message += `\n\n<b>Status</b>\nProcessing`;
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Automatically Approved`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n<b>Requested By</b>\n${username}`;
|
||||
message += `\n\n<b>Status</b>\nProcessing`;
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
messageTitle = 'Now available!';
|
||||
message += `${title}\n\n`;
|
||||
message += `${plot}\n\n`;
|
||||
message += `<b>Requested By</b>\n${user}\n\n`;
|
||||
message += `<b>Status</b>\nAvailable\n`;
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Now Available`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n<b>Requested By</b>\n${username}`;
|
||||
message += `\n\n<b>Status</b>\nAvailable`;
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
messageTitle = 'Request Declined';
|
||||
message += `${title}\n\n`;
|
||||
message += `${plot}\n\n`;
|
||||
message += `<b>Requested By</b>\n${user}\n\n`;
|
||||
message += `<b>Status</b>\nDeclined\n`;
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Declined`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n<b>Requested By</b>\n${username}`;
|
||||
message += `\n\n<b>Status</b>\nDeclined`;
|
||||
priority = 1;
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
messageTitle = `Failed ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n<b>Requested By</b>\n${username}`;
|
||||
message += `\n\n<b>Status</b>\nFailed`;
|
||||
priority = 1;
|
||||
break;
|
||||
case Notification.TEST_NOTIFICATION:
|
||||
messageTitle = 'Test Notification';
|
||||
message += `${title}\n\n`;
|
||||
message += `${plot}\n\n`;
|
||||
message += `<b>Requested By</b>\n${user}\n`;
|
||||
message += `${plot}`;
|
||||
break;
|
||||
}
|
||||
|
||||
if (settings.main.applicationUrl && payload.media) {
|
||||
const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
|
||||
message += `<a href="${actionUrl}">Open in Overseerr</a>`;
|
||||
url = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
|
||||
url_title = `Open in ${settings.main.applicationTitle}`;
|
||||
}
|
||||
|
||||
return { title: messageTitle, message };
|
||||
return {
|
||||
title: messageTitle,
|
||||
message,
|
||||
url,
|
||||
url_title,
|
||||
priority,
|
||||
};
|
||||
}
|
||||
|
||||
public async send(
|
||||
@@ -105,13 +162,22 @@ class PushoverAgent
|
||||
|
||||
const { accessToken, userToken } = this.getSettings().options;
|
||||
|
||||
const { title, message } = this.constructMessageDetails(type, payload);
|
||||
const {
|
||||
title,
|
||||
message,
|
||||
url,
|
||||
url_title,
|
||||
priority,
|
||||
} = this.constructMessageDetails(type, payload);
|
||||
|
||||
await axios.post(endpoint, {
|
||||
token: accessToken,
|
||||
user: userToken,
|
||||
title: title,
|
||||
message: message,
|
||||
url: url,
|
||||
url_title: url_title,
|
||||
priority: priority,
|
||||
html: 1,
|
||||
} as PushoverPayload);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { hasNotificationType, Notification } from '..';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentSlack } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
|
||||
interface EmbedField {
|
||||
type: 'plain_text' | 'mrkdwn';
|
||||
@@ -58,77 +59,80 @@ class SlackAgent
|
||||
payload: NotificationPayload
|
||||
): SlackBlockEmbed {
|
||||
const settings = getSettings();
|
||||
let header = 'Overseerr';
|
||||
let header = '';
|
||||
let actionUrl: string | undefined;
|
||||
|
||||
const fields: EmbedField[] = [];
|
||||
|
||||
if (payload.request) {
|
||||
fields.push({
|
||||
type: 'mrkdwn',
|
||||
text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`,
|
||||
});
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
header = 'New Request';
|
||||
fields.push(
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: `*Requested By*\n${payload.notifyUser.username ?? ''}`,
|
||||
},
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nPending Approval',
|
||||
}
|
||||
);
|
||||
if (settings.main.applicationUrl) {
|
||||
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
|
||||
}
|
||||
header = `New ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
fields.push({
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nPending Approval',
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
header = 'Request Approved';
|
||||
fields.push(
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: `*Requested By*\n${payload.notifyUser.username ?? ''}`,
|
||||
},
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nProcessing Request',
|
||||
}
|
||||
);
|
||||
if (settings.main.applicationUrl) {
|
||||
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
|
||||
}
|
||||
header = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Approved`;
|
||||
fields.push({
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nProcessing',
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
header = 'Request Declined';
|
||||
fields.push(
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: `*Requested By*\n${payload.notifyUser.username ?? ''}`,
|
||||
},
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nDeclined',
|
||||
}
|
||||
);
|
||||
if (settings.main.applicationUrl) {
|
||||
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
|
||||
}
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
header = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Automatically Approved`;
|
||||
fields.push({
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nProcessing',
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
header = 'Now available!';
|
||||
fields.push(
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: `*Requested By*\n${payload.notifyUser.username ?? ''}`,
|
||||
},
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nAvailable',
|
||||
}
|
||||
);
|
||||
|
||||
if (settings.main.applicationUrl) {
|
||||
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
|
||||
}
|
||||
header = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Now Available`;
|
||||
fields.push({
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nAvailable',
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
header = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Declined`;
|
||||
fields.push({
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nDeclined',
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
header = `Failed ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
fields.push({
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nFailed',
|
||||
});
|
||||
break;
|
||||
case Notification.TEST_NOTIFICATION:
|
||||
header = 'Test Notification';
|
||||
break;
|
||||
}
|
||||
|
||||
if (settings.main.applicationUrl && payload.media) {
|
||||
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
|
||||
}
|
||||
|
||||
const blocks: EmbedBlock[] = [
|
||||
@@ -139,14 +143,17 @@ class SlackAgent
|
||||
text: header,
|
||||
},
|
||||
},
|
||||
{
|
||||
];
|
||||
|
||||
if (type !== Notification.TEST_NOTIFICATION) {
|
||||
blocks.push({
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: `*${payload.subject}*`,
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.message) {
|
||||
blocks.push({
|
||||
@@ -191,7 +198,7 @@ class SlackAgent
|
||||
value: 'open_overseerr',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: 'Open Overseerr',
|
||||
text: `Open in ${settings.main.applicationTitle}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -219,7 +226,7 @@ class SlackAgent
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
logger.debug('Sending slack notification', { label: 'Notifications' });
|
||||
logger.debug('Sending Slack notification', { label: 'Notifications' });
|
||||
try {
|
||||
const webhookUrl = this.getSettings().options.webhookUrl;
|
||||
|
||||
|
||||
@@ -2,12 +2,22 @@ import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentTelegram } from '../../settings';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
|
||||
interface TelegramPayload {
|
||||
interface TelegramMessagePayload {
|
||||
text: string;
|
||||
parse_mode: string;
|
||||
chat_id: string;
|
||||
disable_notification: boolean;
|
||||
}
|
||||
|
||||
interface TelegramPhotoPayload {
|
||||
photo: string;
|
||||
caption: string;
|
||||
parse_mode: string;
|
||||
chat_id: string;
|
||||
disable_notification: boolean;
|
||||
}
|
||||
|
||||
class TelegramAgent
|
||||
@@ -51,54 +61,86 @@ class TelegramAgent
|
||||
|
||||
const title = this.escapeText(payload.subject);
|
||||
const plot = this.escapeText(payload.message);
|
||||
const user = this.escapeText(payload.notifyUser.username);
|
||||
const user = this.escapeText(payload.notifyUser.displayName);
|
||||
const applicationTitle = this.escapeText(settings.main.applicationTitle);
|
||||
|
||||
/* eslint-disable no-useless-escape */
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
message += `\*New Request\*\n`;
|
||||
message += `${title}\n\n`;
|
||||
message += `${plot}\n\n`;
|
||||
message += `\*Requested By\*\n${user}\n\n`;
|
||||
message += `\*Status\*\nPending Approval\n`;
|
||||
|
||||
message += `\*New ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n\*Requested By\*\n${user}`;
|
||||
message += `\n\n\*Status\*\nPending Approval`;
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
message += `\*Request Approved\*\n`;
|
||||
message += `${title}\n\n`;
|
||||
message += `${plot}\n\n`;
|
||||
message += `\*Requested By\*\n${user}\n\n`;
|
||||
message += `\*Status\*\nProcessing Request\n`;
|
||||
|
||||
message += `\*${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Approved\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n\*Requested By\*\n${user}`;
|
||||
message += `\n\n\*Status\*\nProcessing`;
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
message += `\*Request Declined\*\n`;
|
||||
message += `${title}\n\n`;
|
||||
message += `${plot}\n\n`;
|
||||
message += `\*Requested By\*\n${user}\n\n`;
|
||||
message += `\*Status\*\nDeclined\n`;
|
||||
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
message += `\*${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Automatically Approved\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n\*Requested By\*\n${user}`;
|
||||
message += `\n\n\*Status\*\nProcessing`;
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
message += `\*Now available\\!\*\n`;
|
||||
message += `${title}\n\n`;
|
||||
message += `${plot}\n\n`;
|
||||
message += `\*Requested By\*\n${user}\n\n`;
|
||||
message += `\*Status\*\nAvailable\n`;
|
||||
|
||||
message += `\*${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Now Available\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n\*Requested By\*\n${user}`;
|
||||
message += `\n\n\*Status\*\nAvailable`;
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
message += `\*${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Declined\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n\*Requested By\*\n${user}`;
|
||||
message += `\n\n\*Status\*\nDeclined`;
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
message += `\*Failed ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n\*Requested By\*\n${user}`;
|
||||
message += `\n\n\*Status\*\nFailed`;
|
||||
break;
|
||||
case Notification.TEST_NOTIFICATION:
|
||||
message += `\*Test Notification\*\n`;
|
||||
message += `${title}\n\n`;
|
||||
message += `${plot}\n\n`;
|
||||
message += `\*Requested By\*\n${user}\n`;
|
||||
|
||||
message += `\*Test Notification\*`;
|
||||
message += `\n\n${plot}`;
|
||||
break;
|
||||
}
|
||||
|
||||
if (settings.main.applicationUrl && payload.media) {
|
||||
const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
|
||||
message += `\[Open in Overseerr\]\(${actionUrl}\)`;
|
||||
message += `\n\n\[Open in ${applicationTitle}\]\(${actionUrl}\)`;
|
||||
}
|
||||
/* eslint-enable */
|
||||
|
||||
@@ -109,17 +151,53 @@ class TelegramAgent
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
logger.debug('Sending telegram notification', { label: 'Notifications' });
|
||||
logger.debug('Sending Telegram notification', { label: 'Notifications' });
|
||||
try {
|
||||
const endpoint = `${this.baseUrl}bot${
|
||||
this.getSettings().options.botAPI
|
||||
}/sendMessage`;
|
||||
}/${payload.image ? 'sendPhoto' : 'sendMessage'}`;
|
||||
|
||||
await axios.post(endpoint, {
|
||||
text: this.buildMessage(type, payload),
|
||||
parse_mode: 'MarkdownV2',
|
||||
chat_id: `${this.getSettings().options.chatId}`,
|
||||
} as TelegramPayload);
|
||||
// Send system notification
|
||||
await (payload.image
|
||||
? axios.post(endpoint, {
|
||||
photo: payload.image,
|
||||
caption: this.buildMessage(type, payload),
|
||||
parse_mode: 'MarkdownV2',
|
||||
chat_id: `${this.getSettings().options.chatId}`,
|
||||
disable_notification: this.getSettings().options.sendSilently,
|
||||
} as TelegramPhotoPayload)
|
||||
: axios.post(endpoint, {
|
||||
text: this.buildMessage(type, payload),
|
||||
parse_mode: 'MarkdownV2',
|
||||
chat_id: `${this.getSettings().options.chatId}`,
|
||||
disable_notification: this.getSettings().options.sendSilently,
|
||||
} as TelegramMessagePayload));
|
||||
|
||||
// Send user notification
|
||||
if (
|
||||
this.userNotificationTypes.includes(type) &&
|
||||
payload.notifyUser.settings?.enableNotifications &&
|
||||
payload.notifyUser.settings?.telegramChatId &&
|
||||
payload.notifyUser.settings?.telegramChatId !==
|
||||
this.getSettings().options.chatId
|
||||
) {
|
||||
await (payload.image
|
||||
? axios.post(endpoint, {
|
||||
photo: payload.image,
|
||||
caption: this.buildMessage(type, payload),
|
||||
parse_mode: 'MarkdownV2',
|
||||
chat_id: `${payload.notifyUser.settings.telegramChatId}`,
|
||||
disable_notification:
|
||||
payload.notifyUser.settings.telegramSendSilently,
|
||||
} as TelegramPhotoPayload)
|
||||
: axios.post(endpoint, {
|
||||
text: this.buildMessage(type, payload),
|
||||
parse_mode: 'MarkdownV2',
|
||||
chat_id: `${payload.notifyUser.settings.telegramChatId}`,
|
||||
disable_notification:
|
||||
payload.notifyUser.settings.telegramSendSilently,
|
||||
} as TelegramMessagePayload));
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
||||
@@ -16,9 +16,11 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
|
||||
subject: 'subject',
|
||||
message: 'message',
|
||||
image: 'image',
|
||||
notifyuser_username: 'notifyUser.username',
|
||||
notifyuser_username: 'notifyUser.displayName',
|
||||
notifyuser_email: 'notifyUser.email',
|
||||
notifyuser_avatar: 'notifyUser.avatar',
|
||||
notifyuser_settings_discordId: 'notifyUser.settings.discordId',
|
||||
notifyuser_settings_telegramChatId: 'notifyUser.settings.telegramChatId',
|
||||
media_tmdbid: 'media.tmdbId',
|
||||
media_imdbid: 'media.imdbId',
|
||||
media_tvdbid: 'media.tvdbId',
|
||||
@@ -27,6 +29,7 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
|
||||
payload.media?.status ? MediaStatus[payload.media?.status] : '',
|
||||
media_status4k: (payload) =>
|
||||
payload.media?.status ? MediaStatus[payload.media?.status4k] : '',
|
||||
request_id: 'request.id',
|
||||
};
|
||||
|
||||
class WebhookAgent
|
||||
@@ -60,6 +63,14 @@ class WebhookAgent
|
||||
}
|
||||
delete finalPayload[key];
|
||||
key = 'media';
|
||||
} else if (key === '{{request}}') {
|
||||
if (payload.request) {
|
||||
finalPayload.request = finalPayload[key];
|
||||
} else {
|
||||
finalPayload.request = null;
|
||||
}
|
||||
delete finalPayload[key];
|
||||
key = 'request';
|
||||
}
|
||||
|
||||
if (typeof finalPayload[key] === 'string') {
|
||||
@@ -127,7 +138,7 @@ class WebhookAgent
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Error sending Webhook notification', {
|
||||
logger.error('Error sending webhook notification', {
|
||||
label: 'Notifications',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logger from '../../logger';
|
||||
import { getSettings } from '../settings';
|
||||
import type { NotificationAgent, NotificationPayload } from './agents/agent';
|
||||
|
||||
export enum Notification {
|
||||
@@ -8,6 +9,7 @@ export enum Notification {
|
||||
MEDIA_FAILED = 16,
|
||||
TEST_NOTIFICATION = 32,
|
||||
MEDIA_DECLINED = 64,
|
||||
MEDIA_AUTO_APPROVED = 128,
|
||||
}
|
||||
|
||||
export const hasNotificationType = (
|
||||
@@ -43,11 +45,12 @@ class NotificationManager {
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): void {
|
||||
const settings = getSettings().notifications;
|
||||
logger.info(`Sending notification for ${Notification[type]}`, {
|
||||
label: 'Notifications',
|
||||
});
|
||||
this.activeAgents.forEach((agent) => {
|
||||
if (agent.shouldSend(type)) {
|
||||
if (settings.enabled && agent.shouldSend(type, payload)) {
|
||||
agent.send(type, payload);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,6 +13,14 @@ export enum Permission {
|
||||
REQUEST_4K_MOVIE = 2048,
|
||||
REQUEST_4K_TV = 4096,
|
||||
REQUEST_ADVANCED = 8192,
|
||||
REQUEST_VIEW = 16384,
|
||||
AUTO_APPROVE_4K = 32768,
|
||||
AUTO_APPROVE_4K_MOVIE = 65536,
|
||||
AUTO_APPROVE_4K_TV = 131072,
|
||||
}
|
||||
|
||||
export interface PermissionCheckOptions {
|
||||
type: 'and' | 'or';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,10 +30,12 @@ export enum Permission {
|
||||
*
|
||||
* @param permissions Single permission or array of permissions
|
||||
* @param value users current permission value
|
||||
* @param options Extra options to control permission check behavior (mainly for arrays)
|
||||
*/
|
||||
export const hasPermission = (
|
||||
permissions: Permission | Permission[],
|
||||
value: number
|
||||
value: number,
|
||||
options: PermissionCheckOptions = { type: 'and' }
|
||||
): boolean => {
|
||||
let total = 0;
|
||||
|
||||
@@ -35,8 +45,15 @@ export const hasPermission = (
|
||||
}
|
||||
|
||||
if (Array.isArray(permissions)) {
|
||||
// Combine all permission values into one
|
||||
total = permissions.reduce((a, v) => a + v, 0);
|
||||
if (value & Permission.ADMIN) {
|
||||
return true;
|
||||
}
|
||||
switch (options.type) {
|
||||
case 'and':
|
||||
return permissions.every((permission) => !!(value & permission));
|
||||
case 'or':
|
||||
return permissions.some((permission) => !!(value & permission));
|
||||
}
|
||||
} else {
|
||||
total = permissions;
|
||||
}
|
||||
|
||||
616
server/lib/scanners/baseScanner.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
import { getRepository } from 'typeorm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import TheMovieDb from '../../api/themoviedb';
|
||||
import { MediaStatus, MediaType } from '../../constants/media';
|
||||
import Media from '../../entity/Media';
|
||||
import Season from '../../entity/Season';
|
||||
import logger from '../../logger';
|
||||
import AsyncLock from '../../utils/asyncLock';
|
||||
import { getSettings } from '../settings';
|
||||
|
||||
// Default scan rates (can be overidden)
|
||||
const BUNDLE_SIZE = 20;
|
||||
const UPDATE_RATE = 4 * 1000;
|
||||
|
||||
export type StatusBase = {
|
||||
running: boolean;
|
||||
progress: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export interface RunnableScanner<T> {
|
||||
run: () => Promise<void>;
|
||||
status: () => T & StatusBase;
|
||||
}
|
||||
|
||||
export interface MediaIds {
|
||||
tmdbId: number;
|
||||
imdbId?: string;
|
||||
tvdbId?: number;
|
||||
isHama?: boolean;
|
||||
}
|
||||
|
||||
interface ProcessOptions {
|
||||
is4k?: boolean;
|
||||
mediaAddedAt?: Date;
|
||||
ratingKey?: string;
|
||||
serviceId?: number;
|
||||
externalServiceId?: number;
|
||||
externalServiceSlug?: string;
|
||||
title?: string;
|
||||
processing?: boolean;
|
||||
}
|
||||
|
||||
export interface ProcessableSeason {
|
||||
seasonNumber: number;
|
||||
totalEpisodes: number;
|
||||
episodes: number;
|
||||
episodes4k: number;
|
||||
is4kOverride?: boolean;
|
||||
processing?: boolean;
|
||||
}
|
||||
|
||||
class BaseScanner<T> {
|
||||
private bundleSize;
|
||||
private updateRate;
|
||||
protected progress = 0;
|
||||
protected items: T[] = [];
|
||||
protected scannerName: string;
|
||||
protected enable4kMovie = false;
|
||||
protected enable4kShow = false;
|
||||
protected sessionId: string;
|
||||
protected running = false;
|
||||
readonly asyncLock = new AsyncLock();
|
||||
readonly tmdb = new TheMovieDb();
|
||||
|
||||
protected constructor(
|
||||
scannerName: string,
|
||||
{
|
||||
updateRate,
|
||||
bundleSize,
|
||||
}: {
|
||||
updateRate?: number;
|
||||
bundleSize?: number;
|
||||
} = {}
|
||||
) {
|
||||
this.scannerName = scannerName;
|
||||
this.bundleSize = bundleSize ?? BUNDLE_SIZE;
|
||||
this.updateRate = updateRate ?? UPDATE_RATE;
|
||||
}
|
||||
|
||||
private async getExisting(tmdbId: number, mediaType: MediaType) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const existing = await mediaRepository.findOne({
|
||||
where: { tmdbId: tmdbId, mediaType },
|
||||
});
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
protected async processMovie(
|
||||
tmdbId: number,
|
||||
{
|
||||
is4k = false,
|
||||
mediaAddedAt,
|
||||
ratingKey,
|
||||
serviceId,
|
||||
externalServiceId,
|
||||
externalServiceSlug,
|
||||
processing = false,
|
||||
title = 'Unknown Title',
|
||||
}: ProcessOptions = {}
|
||||
): Promise<void> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
await this.asyncLock.dispatch(tmdbId, async () => {
|
||||
const existing = await this.getExisting(tmdbId, MediaType.MOVIE);
|
||||
|
||||
if (existing) {
|
||||
let changedExisting = false;
|
||||
|
||||
if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) {
|
||||
existing[is4k ? 'status4k' : 'status'] = processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.AVAILABLE;
|
||||
if (mediaAddedAt) {
|
||||
existing.mediaAddedAt = mediaAddedAt;
|
||||
}
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (!changedExisting && !existing.mediaAddedAt && mediaAddedAt) {
|
||||
existing.mediaAddedAt = mediaAddedAt;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
ratingKey &&
|
||||
existing[is4k ? 'ratingKey4k' : 'ratingKey'] !== ratingKey
|
||||
) {
|
||||
existing[is4k ? 'ratingKey4k' : 'ratingKey'] = ratingKey;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
serviceId !== undefined &&
|
||||
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
|
||||
) {
|
||||
existing[is4k ? 'serviceId4k' : 'serviceId'] = serviceId;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
externalServiceId !== undefined &&
|
||||
existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] !==
|
||||
externalServiceId
|
||||
) {
|
||||
existing[
|
||||
is4k ? 'externalServiceId4k' : 'externalServiceId'
|
||||
] = externalServiceId;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
externalServiceSlug !== undefined &&
|
||||
existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
|
||||
externalServiceSlug
|
||||
) {
|
||||
existing[
|
||||
is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
|
||||
] = externalServiceSlug;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (changedExisting) {
|
||||
await mediaRepository.save(existing);
|
||||
this.log(
|
||||
`Media for ${title} exists. Changed were detected and the title will be updated.`,
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
this.log(`Title already exists and no changes detected for ${title}`);
|
||||
}
|
||||
} else {
|
||||
const newMedia = new Media();
|
||||
newMedia.tmdbId = tmdbId;
|
||||
|
||||
newMedia.status =
|
||||
!is4k && !processing
|
||||
? MediaStatus.AVAILABLE
|
||||
: !is4k && processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN;
|
||||
newMedia.status4k =
|
||||
is4k && this.enable4kMovie && !processing
|
||||
? MediaStatus.AVAILABLE
|
||||
: is4k && this.enable4kMovie && processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN;
|
||||
newMedia.mediaType = MediaType.MOVIE;
|
||||
newMedia.serviceId = !is4k ? serviceId : undefined;
|
||||
newMedia.serviceId4k = is4k ? serviceId : undefined;
|
||||
newMedia.externalServiceId = !is4k ? externalServiceId : undefined;
|
||||
newMedia.externalServiceId4k = is4k ? externalServiceId : undefined;
|
||||
newMedia.externalServiceSlug = !is4k ? externalServiceSlug : undefined;
|
||||
newMedia.externalServiceSlug4k = is4k ? externalServiceSlug : undefined;
|
||||
|
||||
if (mediaAddedAt) {
|
||||
newMedia.mediaAddedAt = mediaAddedAt;
|
||||
}
|
||||
|
||||
if (ratingKey) {
|
||||
newMedia.ratingKey = !is4k ? ratingKey : undefined;
|
||||
newMedia.ratingKey4k =
|
||||
is4k && this.enable4kMovie ? ratingKey : undefined;
|
||||
}
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved new media: ${title}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* processShow takes a TMDb ID and an array of ProcessableSeasons, which
|
||||
* should include the total episodes a sesaon has + the total available
|
||||
* episodes that each season currently has. Unlike processMovie, this method
|
||||
* does not take an `is4k` option. We handle both the 4k _and_ non 4k status
|
||||
* in one method.
|
||||
*
|
||||
* Note: If 4k is not enable, ProcessableSeasons should combine their episode counts
|
||||
* into the normal episodes properties and avoid using the 4k properties.
|
||||
*/
|
||||
protected async processShow(
|
||||
tmdbId: number,
|
||||
tvdbId: number,
|
||||
seasons: ProcessableSeason[],
|
||||
{
|
||||
mediaAddedAt,
|
||||
ratingKey,
|
||||
serviceId,
|
||||
externalServiceId,
|
||||
externalServiceSlug,
|
||||
is4k = false,
|
||||
title = 'Unknown Title',
|
||||
}: ProcessOptions = {}
|
||||
): Promise<void> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
await this.asyncLock.dispatch(tmdbId, async () => {
|
||||
const media = await this.getExisting(tmdbId, MediaType.TV);
|
||||
|
||||
const newSeasons: Season[] = [];
|
||||
|
||||
const currentStandardSeasonsAvailable = (
|
||||
media?.seasons.filter(
|
||||
(season) => season.status === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
const current4kSeasonsAvailable = (
|
||||
media?.seasons.filter(
|
||||
(season) => season.status4k === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
for (const season of seasons) {
|
||||
const existingSeason = media?.seasons.find(
|
||||
(es) => es.seasonNumber === season.seasonNumber
|
||||
);
|
||||
|
||||
// We update the rating keys in the seasons loop because we need episode counts
|
||||
if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
|
||||
media.ratingKey = ratingKey;
|
||||
}
|
||||
|
||||
if (
|
||||
media &&
|
||||
season.episodes4k > 0 &&
|
||||
this.enable4kShow &&
|
||||
media.ratingKey4k !== ratingKey
|
||||
) {
|
||||
media.ratingKey4k = ratingKey;
|
||||
}
|
||||
|
||||
if (existingSeason) {
|
||||
// Here we update seasons if they already exist.
|
||||
// If the season is already marked as available, we
|
||||
// force it to stay available (to avoid competing scanners)
|
||||
existingSeason.status =
|
||||
(season.totalEpisodes === season.episodes && season.episodes > 0) ||
|
||||
existingSeason.status === MediaStatus.AVAILABLE
|
||||
? MediaStatus.AVAILABLE
|
||||
: season.episodes > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: !season.is4kOverride && season.processing
|
||||
? MediaStatus.PROCESSING
|
||||
: existingSeason.status;
|
||||
|
||||
// Same thing here, except we only do updates if 4k is enabled
|
||||
existingSeason.status4k =
|
||||
(this.enable4kShow &&
|
||||
season.episodes4k === season.totalEpisodes &&
|
||||
season.episodes4k > 0) ||
|
||||
existingSeason.status4k === MediaStatus.AVAILABLE
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow && season.episodes4k > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: season.is4kOverride && season.processing
|
||||
? MediaStatus.PROCESSING
|
||||
: existingSeason.status4k;
|
||||
} else {
|
||||
newSeasons.push(
|
||||
new Season({
|
||||
seasonNumber: season.seasonNumber,
|
||||
status:
|
||||
season.totalEpisodes === season.episodes && season.episodes > 0
|
||||
? MediaStatus.AVAILABLE
|
||||
: season.episodes > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: !season.is4kOverride && season.processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k:
|
||||
this.enable4kShow &&
|
||||
season.totalEpisodes === season.episodes4k &&
|
||||
season.episodes4k > 0
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow && season.episodes4k > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: season.is4kOverride && season.processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isAllStandardSeasons =
|
||||
seasons.length &&
|
||||
seasons.every(
|
||||
(season) =>
|
||||
season.episodes === season.totalEpisodes && season.episodes > 0
|
||||
);
|
||||
|
||||
const isAll4kSeasons =
|
||||
seasons.length &&
|
||||
seasons.every(
|
||||
(season) =>
|
||||
season.episodes4k === season.totalEpisodes && season.episodes4k > 0
|
||||
);
|
||||
|
||||
if (media) {
|
||||
media.seasons = [...media.seasons, ...newSeasons];
|
||||
|
||||
const newStandardSeasonsAvailable = (
|
||||
media.seasons.filter(
|
||||
(season) => season.status === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
const new4kSeasonsAvailable = (
|
||||
media.seasons.filter(
|
||||
(season) => season.status4k === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
// If at least one new season has become available, update
|
||||
// the lastSeasonChange field so we can trigger notifications
|
||||
if (newStandardSeasonsAvailable > currentStandardSeasonsAvailable) {
|
||||
this.log(
|
||||
`Detected ${
|
||||
newStandardSeasonsAvailable - currentStandardSeasonsAvailable
|
||||
} new standard season(s) for ${title}`,
|
||||
'debug'
|
||||
);
|
||||
media.lastSeasonChange = new Date();
|
||||
|
||||
if (mediaAddedAt) {
|
||||
media.mediaAddedAt = mediaAddedAt;
|
||||
}
|
||||
}
|
||||
|
||||
if (new4kSeasonsAvailable > current4kSeasonsAvailable) {
|
||||
this.log(
|
||||
`Detected ${
|
||||
new4kSeasonsAvailable - current4kSeasonsAvailable
|
||||
} new 4K season(s) for ${title}`,
|
||||
'debug'
|
||||
);
|
||||
media.lastSeasonChange = new Date();
|
||||
}
|
||||
|
||||
if (!media.mediaAddedAt && mediaAddedAt) {
|
||||
media.mediaAddedAt = mediaAddedAt;
|
||||
}
|
||||
|
||||
if (serviceId !== undefined) {
|
||||
media[is4k ? 'serviceId4k' : 'serviceId'] = serviceId;
|
||||
}
|
||||
|
||||
if (externalServiceId !== undefined) {
|
||||
media[
|
||||
is4k ? 'externalServiceId4k' : 'externalServiceId'
|
||||
] = externalServiceId;
|
||||
}
|
||||
|
||||
if (externalServiceSlug !== undefined) {
|
||||
media[
|
||||
is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
|
||||
] = externalServiceSlug;
|
||||
}
|
||||
|
||||
// If the show is already available, and there are no new seasons, dont adjust
|
||||
// the status
|
||||
const shouldStayAvailable =
|
||||
media.status === MediaStatus.AVAILABLE &&
|
||||
newSeasons.filter((season) => season.status !== MediaStatus.UNKNOWN)
|
||||
.length === 0;
|
||||
const shouldStayAvailable4k =
|
||||
media.status4k === MediaStatus.AVAILABLE &&
|
||||
newSeasons.filter((season) => season.status4k !== MediaStatus.UNKNOWN)
|
||||
.length === 0;
|
||||
|
||||
media.status =
|
||||
isAllStandardSeasons || shouldStayAvailable
|
||||
? MediaStatus.AVAILABLE
|
||||
: media.seasons.some(
|
||||
(season) =>
|
||||
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: media.seasons.some(
|
||||
(season) => season.status === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN;
|
||||
media.status4k =
|
||||
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow &&
|
||||
media.seasons.some(
|
||||
(season) =>
|
||||
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status4k === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: media.seasons.some(
|
||||
(season) => season.status4k === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
this.log(`Updating existing title: ${title}`);
|
||||
} else {
|
||||
const newMedia = new Media({
|
||||
mediaType: MediaType.TV,
|
||||
seasons: newSeasons,
|
||||
tmdbId,
|
||||
tvdbId,
|
||||
mediaAddedAt,
|
||||
serviceId: !is4k ? serviceId : undefined,
|
||||
serviceId4k: is4k ? serviceId : undefined,
|
||||
externalServiceId: !is4k ? externalServiceId : undefined,
|
||||
externalServiceId4k: is4k ? externalServiceId : undefined,
|
||||
externalServiceSlug: !is4k ? externalServiceSlug : undefined,
|
||||
externalServiceSlug4k: is4k ? externalServiceSlug : undefined,
|
||||
ratingKey: newSeasons.some(
|
||||
(sn) =>
|
||||
sn.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
sn.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? ratingKey
|
||||
: undefined,
|
||||
ratingKey4k:
|
||||
this.enable4kShow &&
|
||||
newSeasons.some(
|
||||
(sn) =>
|
||||
sn.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
sn.status4k === MediaStatus.AVAILABLE
|
||||
)
|
||||
? ratingKey
|
||||
: undefined,
|
||||
status: isAllStandardSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: newSeasons.some(
|
||||
(season) =>
|
||||
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: newSeasons.some(
|
||||
(season) => season.status === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k:
|
||||
isAll4kSeasons && this.enable4kShow
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow &&
|
||||
newSeasons.some(
|
||||
(season) =>
|
||||
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status4k === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: newSeasons.some(
|
||||
(season) => season.status4k === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
});
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${title}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Call startRun from child class whenever a run is starting to
|
||||
* ensure required values are set
|
||||
*
|
||||
* Returns the session ID which is requried for the cleanup method
|
||||
*/
|
||||
protected startRun(): string {
|
||||
const settings = getSettings();
|
||||
const sessionId = uuid();
|
||||
this.sessionId = sessionId;
|
||||
|
||||
this.log('Scan starting', 'info', { sessionId });
|
||||
|
||||
this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k);
|
||||
if (this.enable4kMovie) {
|
||||
this.log(
|
||||
'At least one 4K Radarr server was detected. 4K movie detection is now enabled',
|
||||
'info'
|
||||
);
|
||||
}
|
||||
|
||||
this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k);
|
||||
if (this.enable4kShow) {
|
||||
this.log(
|
||||
'At least one 4K Sonarr server was detected. 4K series detection is now enabled',
|
||||
'info'
|
||||
);
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call at end of run loop to perform cleanup
|
||||
*/
|
||||
protected endRun(sessionId: string): void {
|
||||
if (this.sessionId === sessionId) {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
public cancel(): void {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
protected async loop(
|
||||
processFn: (item: T) => Promise<void>,
|
||||
{
|
||||
start = 0,
|
||||
end = this.bundleSize,
|
||||
sessionId,
|
||||
}: {
|
||||
start?: number;
|
||||
end?: number;
|
||||
sessionId?: string;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const slicedItems = this.items.slice(start, end);
|
||||
|
||||
if (!this.running) {
|
||||
throw new Error('Sync was aborted.');
|
||||
}
|
||||
|
||||
if (this.sessionId !== sessionId) {
|
||||
throw new Error('New session was started. Old session aborted.');
|
||||
}
|
||||
|
||||
if (start < this.items.length) {
|
||||
this.progress = start;
|
||||
await this.processItems(processFn, slicedItems);
|
||||
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
setTimeout(() => {
|
||||
this.loop(processFn, {
|
||||
start: start + this.bundleSize,
|
||||
end: end + this.bundleSize,
|
||||
sessionId,
|
||||
})
|
||||
.then(() => resolve())
|
||||
.catch((e) => reject(new Error(e.message)));
|
||||
}, this.updateRate)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async processItems(
|
||||
processFn: (items: T) => Promise<void>,
|
||||
items: T[]
|
||||
) {
|
||||
await Promise.all(
|
||||
items.map(async (item) => {
|
||||
await processFn(item);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
protected log(
|
||||
message: string,
|
||||
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
|
||||
optional?: Record<string, unknown>
|
||||
): void {
|
||||
logger[level](message, { label: this.scannerName, ...optional });
|
||||
}
|
||||
}
|
||||
|
||||
export default BaseScanner;
|
||||
463
server/lib/scanners/plex/index.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
import { uniqWith } from 'lodash';
|
||||
import { getRepository } from 'typeorm';
|
||||
import animeList from '../../../api/animelist';
|
||||
import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../../api/plexapi';
|
||||
import { TmdbTvDetails } from '../../../api/themoviedb/interfaces';
|
||||
import { User } from '../../../entity/User';
|
||||
import { getSettings, Library } from '../../settings';
|
||||
import BaseScanner, {
|
||||
MediaIds,
|
||||
RunnableScanner,
|
||||
StatusBase,
|
||||
ProcessableSeason,
|
||||
} from '../baseScanner';
|
||||
|
||||
const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
|
||||
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
|
||||
const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/);
|
||||
const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/);
|
||||
const plexRegex = new RegExp(/plex:\/\//);
|
||||
// Hama agent uses ASS naming, see details here:
|
||||
// https://github.com/ZeroQI/Absolute-Series-Scanner/blob/master/README.md#forcing-the-movieseries-id
|
||||
const hamaTvdbRegex = new RegExp(/hama:\/\/tvdb[0-9]?-([0-9]+)/);
|
||||
const hamaAnidbRegex = new RegExp(/hama:\/\/anidb[0-9]?-([0-9]+)/);
|
||||
const HAMA_AGENT = 'com.plexapp.agents.hama';
|
||||
|
||||
type SyncStatus = StatusBase & {
|
||||
currentLibrary: Library;
|
||||
libraries: Library[];
|
||||
};
|
||||
|
||||
class PlexScanner
|
||||
extends BaseScanner<PlexLibraryItem>
|
||||
implements RunnableScanner<SyncStatus> {
|
||||
private plexClient: PlexAPI;
|
||||
private libraries: Library[];
|
||||
private currentLibrary: Library;
|
||||
private isRecentOnly = false;
|
||||
|
||||
public constructor(isRecentOnly = false) {
|
||||
super('Plex Scan');
|
||||
this.isRecentOnly = isRecentOnly;
|
||||
}
|
||||
|
||||
public status(): SyncStatus {
|
||||
return {
|
||||
running: this.running,
|
||||
progress: this.progress,
|
||||
total: this.items.length,
|
||||
currentLibrary: this.currentLibrary,
|
||||
libraries: this.libraries,
|
||||
};
|
||||
}
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const settings = getSettings();
|
||||
const sessionId = this.startRun();
|
||||
try {
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOne({
|
||||
select: ['id', 'plexToken'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
return this.log('No admin configured. Plex scan skipped.', 'warn');
|
||||
}
|
||||
|
||||
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
|
||||
|
||||
this.libraries = settings.plex.libraries.filter(
|
||||
(library) => library.enabled
|
||||
);
|
||||
|
||||
const hasHama = await this.hasHamaAgent();
|
||||
if (hasHama) {
|
||||
await animeList.sync();
|
||||
}
|
||||
|
||||
if (this.isRecentOnly) {
|
||||
for (const library of this.libraries) {
|
||||
this.currentLibrary = library;
|
||||
this.log(
|
||||
`Beginning to process recently added for library: ${library.name}`,
|
||||
'info'
|
||||
);
|
||||
const libraryItems = await this.plexClient.getRecentlyAdded(
|
||||
library.id
|
||||
);
|
||||
|
||||
// Bundle items up by rating keys
|
||||
this.items = uniqWith(libraryItems, (mediaA, mediaB) => {
|
||||
if (mediaA.grandparentRatingKey && mediaB.grandparentRatingKey) {
|
||||
return (
|
||||
mediaA.grandparentRatingKey === mediaB.grandparentRatingKey
|
||||
);
|
||||
}
|
||||
|
||||
if (mediaA.parentRatingKey && mediaB.parentRatingKey) {
|
||||
return mediaA.parentRatingKey === mediaB.parentRatingKey;
|
||||
}
|
||||
|
||||
return mediaA.ratingKey === mediaB.ratingKey;
|
||||
});
|
||||
|
||||
await this.loop(this.processItem.bind(this), { sessionId });
|
||||
}
|
||||
} else {
|
||||
for (const library of this.libraries) {
|
||||
this.currentLibrary = library;
|
||||
this.log(`Beginning to process library: ${library.name}`, 'info');
|
||||
this.items = await this.plexClient.getLibraryContents(library.id);
|
||||
await this.loop(this.processItem.bind(this), { sessionId });
|
||||
}
|
||||
}
|
||||
this.log(
|
||||
this.isRecentOnly
|
||||
? 'Recently Added Scan Complete'
|
||||
: 'Full Scan Complete',
|
||||
'info'
|
||||
);
|
||||
} catch (e) {
|
||||
this.log('Scan interrupted', 'error', { errorMessage: e.message });
|
||||
} finally {
|
||||
this.endRun(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private async processItem(plexitem: PlexLibraryItem) {
|
||||
try {
|
||||
if (plexitem.type === 'movie') {
|
||||
await this.processPlexMovie(plexitem);
|
||||
} else if (
|
||||
plexitem.type === 'show' ||
|
||||
plexitem.type === 'episode' ||
|
||||
plexitem.type === 'season'
|
||||
) {
|
||||
await this.processPlexShow(plexitem);
|
||||
}
|
||||
} catch (e) {
|
||||
this.log('Failed to process Plex media', 'error', {
|
||||
errorMessage: e.message,
|
||||
title: plexitem.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async processPlexMovie(plexitem: PlexLibraryItem) {
|
||||
const mediaIds = await this.getMediaIds(plexitem);
|
||||
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
|
||||
|
||||
const has4k = metadata.Media.some(
|
||||
(media) => media.videoResolution === '4k'
|
||||
);
|
||||
|
||||
await this.processMovie(mediaIds.tmdbId, {
|
||||
is4k: has4k && this.enable4kMovie,
|
||||
mediaAddedAt: new Date(plexitem.addedAt * 1000),
|
||||
ratingKey: plexitem.ratingKey,
|
||||
title: plexitem.title,
|
||||
});
|
||||
}
|
||||
|
||||
private async processPlexMovieByTmdbId(
|
||||
plexitem: PlexMetadata,
|
||||
tmdbId: number
|
||||
) {
|
||||
const has4k = plexitem.Media.some(
|
||||
(media) => media.videoResolution === '4k'
|
||||
);
|
||||
|
||||
await this.processMovie(tmdbId, {
|
||||
is4k: has4k && this.enable4kMovie,
|
||||
mediaAddedAt: new Date(plexitem.addedAt * 1000),
|
||||
ratingKey: plexitem.ratingKey,
|
||||
title: plexitem.title,
|
||||
});
|
||||
}
|
||||
|
||||
private async processPlexShow(plexitem: PlexLibraryItem) {
|
||||
const ratingKey =
|
||||
plexitem.grandparentRatingKey ??
|
||||
plexitem.parentRatingKey ??
|
||||
plexitem.ratingKey;
|
||||
const metadata = await this.plexClient.getMetadata(ratingKey, {
|
||||
includeChildren: true,
|
||||
});
|
||||
|
||||
const mediaIds = await this.getMediaIds(metadata);
|
||||
|
||||
// If the media is from HAMA, and doesn't have a TVDb ID, we will treat it
|
||||
// as a special HAMA movie
|
||||
if (mediaIds.tmdbId && !mediaIds.tvdbId && mediaIds.isHama) {
|
||||
this.processHamaMovie(metadata, mediaIds.tmdbId);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the media is from HAMA and we have a TVDb ID, we will attempt
|
||||
// to process any specials that may exist
|
||||
if (mediaIds.tvdbId && mediaIds.isHama) {
|
||||
await this.processHamaSpecials(metadata, mediaIds.tvdbId);
|
||||
}
|
||||
|
||||
const tvShow = await this.tmdb.getTvShow({ tvId: mediaIds.tmdbId });
|
||||
|
||||
const seasons = tvShow.seasons;
|
||||
const processableSeasons: ProcessableSeason[] = [];
|
||||
|
||||
const filteredSeasons = seasons.filter((sn) => sn.season_number !== 0);
|
||||
|
||||
for (const season of filteredSeasons) {
|
||||
const matchedPlexSeason = metadata.Children?.Metadata.find(
|
||||
(md) => Number(md.index) === season.season_number
|
||||
);
|
||||
|
||||
if (matchedPlexSeason) {
|
||||
// If we have a matched Plex season, get its children metadata so we can check details
|
||||
const episodes = await this.plexClient.getChildrenMetadata(
|
||||
matchedPlexSeason.ratingKey
|
||||
);
|
||||
// Total episodes that are in standard definition (not 4k)
|
||||
const totalStandard = episodes.filter((episode) =>
|
||||
!this.enable4kShow
|
||||
? true
|
||||
: episode.Media.some((media) => media.videoResolution !== '4k')
|
||||
).length;
|
||||
|
||||
// Total episodes that are in 4k
|
||||
const total4k = this.enable4kShow
|
||||
? episodes.filter((episode) =>
|
||||
episode.Media.some((media) => media.videoResolution === '4k')
|
||||
).length
|
||||
: 0;
|
||||
|
||||
processableSeasons.push({
|
||||
seasonNumber: season.season_number,
|
||||
episodes: totalStandard,
|
||||
episodes4k: total4k,
|
||||
totalEpisodes: season.episode_count,
|
||||
});
|
||||
} else {
|
||||
processableSeasons.push({
|
||||
seasonNumber: season.season_number,
|
||||
episodes: 0,
|
||||
episodes4k: 0,
|
||||
totalEpisodes: season.episode_count,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaIds.tvdbId) {
|
||||
await this.processShow(
|
||||
mediaIds.tmdbId,
|
||||
mediaIds.tvdbId ?? tvShow.external_ids.tvdb_id,
|
||||
processableSeasons,
|
||||
{
|
||||
mediaAddedAt: new Date(metadata.addedAt * 1000),
|
||||
ratingKey: ratingKey,
|
||||
title: metadata.title,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async getMediaIds(plexitem: PlexLibraryItem): Promise<MediaIds> {
|
||||
const mediaIds: Partial<MediaIds> = {};
|
||||
// Check if item is using new plex movie/tv agent
|
||||
if (plexitem.guid.match(plexRegex)) {
|
||||
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
|
||||
|
||||
// If there is no Guid field at all, then we bail
|
||||
if (!metadata.Guid) {
|
||||
throw new Error(
|
||||
'No Guid metadata for this title. Skipping. (Try refreshing the metadata in Plex for this media!)'
|
||||
);
|
||||
}
|
||||
|
||||
// Map all IDs to MediaId object
|
||||
metadata.Guid.forEach((ref) => {
|
||||
if (ref.id.match(imdbRegex)) {
|
||||
mediaIds.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined;
|
||||
} else if (ref.id.match(tmdbRegex)) {
|
||||
const tmdbMatch = ref.id.match(tmdbRegex)?.[1];
|
||||
mediaIds.tmdbId = Number(tmdbMatch);
|
||||
} else if (ref.id.match(tvdbRegex)) {
|
||||
const tvdbMatch = ref.id.match(tvdbRegex)?.[1];
|
||||
mediaIds.tvdbId = Number(tvdbMatch);
|
||||
}
|
||||
});
|
||||
|
||||
// If we got an IMDb ID, but no TMDb ID, lookup the TMDb ID with the IMDb ID
|
||||
if (mediaIds.imdbId && !mediaIds.tmdbId) {
|
||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
||||
imdbId: mediaIds.imdbId,
|
||||
});
|
||||
mediaIds.tmdbId = tmdbMovie.id;
|
||||
}
|
||||
// Check if the agent is IMDb
|
||||
} else if (plexitem.guid.match(imdbRegex)) {
|
||||
const imdbMatch = plexitem.guid.match(imdbRegex);
|
||||
if (imdbMatch) {
|
||||
mediaIds.imdbId = imdbMatch[1];
|
||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
||||
imdbId: mediaIds.imdbId,
|
||||
});
|
||||
mediaIds.tmdbId = tmdbMovie.id;
|
||||
}
|
||||
// Check if the agent is TMDb
|
||||
} else if (plexitem.guid.match(tmdbRegex)) {
|
||||
const tmdbMatch = plexitem.guid.match(tmdbRegex);
|
||||
if (tmdbMatch) {
|
||||
mediaIds.tmdbId = Number(tmdbMatch[1]);
|
||||
}
|
||||
// Check if the agent is TVDb
|
||||
} else if (plexitem.guid.match(tvdbRegex)) {
|
||||
const matchedtvdb = plexitem.guid.match(tvdbRegex);
|
||||
|
||||
// If we can find a tvdb Id, use it to get the full tmdb show details
|
||||
if (matchedtvdb) {
|
||||
const show = await this.tmdb.getShowByTvdbId({
|
||||
tvdbId: Number(matchedtvdb[1]),
|
||||
});
|
||||
|
||||
mediaIds.tvdbId = Number(matchedtvdb[1]);
|
||||
mediaIds.tmdbId = show.id;
|
||||
}
|
||||
// Check if the agent (for shows) is TMDb
|
||||
} else if (plexitem.guid.match(tmdbShowRegex)) {
|
||||
const matchedtmdb = plexitem.guid.match(tmdbShowRegex);
|
||||
if (matchedtmdb) {
|
||||
mediaIds.tmdbId = Number(matchedtmdb[1]);
|
||||
}
|
||||
// Check for HAMA (with TVDb guid)
|
||||
} else if (plexitem.guid.match(hamaTvdbRegex)) {
|
||||
const matchedtvdb = plexitem.guid.match(hamaTvdbRegex);
|
||||
|
||||
if (matchedtvdb) {
|
||||
const show = await this.tmdb.getShowByTvdbId({
|
||||
tvdbId: Number(matchedtvdb[1]),
|
||||
});
|
||||
|
||||
mediaIds.tvdbId = Number(matchedtvdb[1]);
|
||||
mediaIds.tmdbId = show.id;
|
||||
// Set isHama to true, so we can know to add special processing to this item
|
||||
mediaIds.isHama = true;
|
||||
}
|
||||
// Check for HAMA (with anidb guid)
|
||||
} else if (plexitem.guid.match(hamaAnidbRegex)) {
|
||||
const matchedhama = plexitem.guid.match(hamaAnidbRegex);
|
||||
|
||||
if (!animeList.isLoaded()) {
|
||||
this.log(
|
||||
`Hama ID ${plexitem.guid} detected, but library agent is not set to Hama`,
|
||||
'warn',
|
||||
{ title: plexitem.title }
|
||||
);
|
||||
} else if (matchedhama) {
|
||||
const anidbId = Number(matchedhama[1]);
|
||||
const result = animeList.getFromAnidbId(anidbId);
|
||||
let tvShow: TmdbTvDetails | null = null;
|
||||
|
||||
// Set isHama to true, so we can know to add special processing to this item
|
||||
mediaIds.isHama = true;
|
||||
|
||||
// First try to lookup the show by TVDb ID
|
||||
if (result?.tvdbId) {
|
||||
const extResponse = await this.tmdb.getByExternalId({
|
||||
externalId: result.tvdbId,
|
||||
type: 'tvdb',
|
||||
});
|
||||
if (extResponse.tv_results[0]) {
|
||||
tvShow = await this.tmdb.getTvShow({
|
||||
tvId: extResponse.tv_results[0].id,
|
||||
});
|
||||
mediaIds.tvdbId = result.tvdbId;
|
||||
mediaIds.tmdbId = tvShow.id;
|
||||
} else {
|
||||
this.log(
|
||||
`Missing TVDB ${result.tvdbId} entry in TMDB for AniDB ${anidbId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!tvShow) {
|
||||
// if lookup of tvshow above failed, then try movie with tmdbid/imdbid
|
||||
// note - some tv shows have imdbid set too, that's why this need to go second
|
||||
if (result?.tmdbId) {
|
||||
mediaIds.tmdbId = result.tmdbId;
|
||||
mediaIds.imdbId = result?.imdbId;
|
||||
} else if (result?.imdbId) {
|
||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
||||
imdbId: result.imdbId,
|
||||
});
|
||||
mediaIds.tmdbId = tmdbMovie.id;
|
||||
mediaIds.imdbId = result.imdbId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mediaIds.tmdbId) {
|
||||
throw new Error('Unable to find TMDb ID');
|
||||
}
|
||||
|
||||
// We check above if we have the TMDb ID, so we can safely assert the type below
|
||||
return mediaIds as MediaIds;
|
||||
}
|
||||
|
||||
// movies with hama agent actually are tv shows with at least one episode in it
|
||||
// try to get first episode of any season - cannot hardcode season or episode number
|
||||
// because sometimes user can have it in other season/ep than s01e01
|
||||
private async processHamaMovie(metadata: PlexMetadata, tmdbId: number) {
|
||||
const season = metadata.Children?.Metadata[0];
|
||||
if (season) {
|
||||
const episodes = await this.plexClient.getChildrenMetadata(
|
||||
season.ratingKey
|
||||
);
|
||||
if (episodes) {
|
||||
await this.processPlexMovieByTmdbId(episodes[0], tmdbId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// this adds all movie episodes from specials season for Hama agent
|
||||
private async processHamaSpecials(metadata: PlexMetadata, tvdbId: number) {
|
||||
const specials = metadata.Children?.Metadata.find(
|
||||
(md) => Number(md.index) === 0
|
||||
);
|
||||
if (specials) {
|
||||
const episodes = await this.plexClient.getChildrenMetadata(
|
||||
specials.ratingKey
|
||||
);
|
||||
if (episodes) {
|
||||
for (const episode of episodes) {
|
||||
const special = animeList.getSpecialEpisode(tvdbId, episode.index);
|
||||
if (special) {
|
||||
if (special.tmdbId) {
|
||||
await this.processPlexMovieByTmdbId(episode, special.tmdbId);
|
||||
} else if (special.imdbId) {
|
||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
||||
imdbId: special.imdbId,
|
||||
});
|
||||
await this.processPlexMovieByTmdbId(episode, tmdbMovie.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checks if any of this.libraries has Hama agent set in Plex
|
||||
private async hasHamaAgent() {
|
||||
const plexLibraries = await this.plexClient.getLibraries();
|
||||
return this.libraries.some((library) =>
|
||||
plexLibraries.some(
|
||||
(plexLibrary) =>
|
||||
plexLibrary.agent === HAMA_AGENT && library.id === plexLibrary.key
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const plexFullScanner = new PlexScanner();
|
||||
export const plexRecentScanner = new PlexScanner(true);
|
||||
94
server/lib/scanners/radarr/index.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { uniqWith } from 'lodash';
|
||||
import RadarrAPI, { RadarrMovie } from '../../../api/radarr';
|
||||
import { getSettings, RadarrSettings } from '../../settings';
|
||||
import BaseScanner, { RunnableScanner, StatusBase } from '../baseScanner';
|
||||
|
||||
type SyncStatus = StatusBase & {
|
||||
currentServer: RadarrSettings;
|
||||
servers: RadarrSettings[];
|
||||
};
|
||||
|
||||
class RadarrScanner
|
||||
extends BaseScanner<RadarrMovie>
|
||||
implements RunnableScanner<SyncStatus> {
|
||||
private servers: RadarrSettings[];
|
||||
private currentServer: RadarrSettings;
|
||||
private radarrApi: RadarrAPI;
|
||||
|
||||
constructor() {
|
||||
super('Radarr Scan', { bundleSize: 50 });
|
||||
}
|
||||
|
||||
public status(): SyncStatus {
|
||||
return {
|
||||
running: this.running,
|
||||
progress: this.progress,
|
||||
total: this.items.length,
|
||||
currentServer: this.currentServer,
|
||||
servers: this.servers,
|
||||
};
|
||||
}
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const settings = getSettings();
|
||||
const sessionId = this.startRun();
|
||||
|
||||
try {
|
||||
this.servers = uniqWith(settings.radarr, (radarrA, radarrB) => {
|
||||
return (
|
||||
radarrA.hostname === radarrB.hostname &&
|
||||
radarrA.port === radarrB.port &&
|
||||
radarrA.baseUrl === radarrB.baseUrl
|
||||
);
|
||||
});
|
||||
|
||||
for (const server of this.servers) {
|
||||
this.currentServer = server;
|
||||
if (server.syncEnabled) {
|
||||
this.log(
|
||||
`Beginning to process Radarr server: ${server.name}`,
|
||||
'info'
|
||||
);
|
||||
|
||||
this.radarrApi = new RadarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: RadarrAPI.buildRadarrUrl(server, '/api/v3'),
|
||||
});
|
||||
|
||||
this.items = await this.radarrApi.getMovies();
|
||||
|
||||
await this.loop(this.processRadarrMovie.bind(this), { sessionId });
|
||||
} else {
|
||||
this.log(`Sync not enabled. Skipping Radarr server: ${server.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.log('Radarr scan complete', 'info');
|
||||
} catch (e) {
|
||||
this.log('Scan interrupted', 'error', { errorMessage: e.message });
|
||||
} finally {
|
||||
this.endRun(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private async processRadarrMovie(radarrMovie: RadarrMovie): Promise<void> {
|
||||
try {
|
||||
const server4k = this.enable4kMovie && this.currentServer.is4k;
|
||||
await this.processMovie(radarrMovie.tmdbId, {
|
||||
is4k: server4k,
|
||||
serviceId: this.currentServer.id,
|
||||
externalServiceId: radarrMovie.id,
|
||||
externalServiceSlug: radarrMovie.titleSlug,
|
||||
title: radarrMovie.title,
|
||||
processing: !radarrMovie.downloaded,
|
||||
});
|
||||
} catch (e) {
|
||||
this.log('Failed to process Radarr media', 'error', {
|
||||
errorMessage: e.message,
|
||||
title: radarrMovie.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const radarrScanner = new RadarrScanner();
|
||||
134
server/lib/scanners/sonarr/index.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { uniqWith } from 'lodash';
|
||||
import { getRepository } from 'typeorm';
|
||||
import SonarrAPI, { SonarrSeries } from '../../../api/sonarr';
|
||||
import Media from '../../../entity/Media';
|
||||
import { getSettings, SonarrSettings } from '../../settings';
|
||||
import BaseScanner, {
|
||||
ProcessableSeason,
|
||||
RunnableScanner,
|
||||
StatusBase,
|
||||
} from '../baseScanner';
|
||||
|
||||
type SyncStatus = StatusBase & {
|
||||
currentServer: SonarrSettings;
|
||||
servers: SonarrSettings[];
|
||||
};
|
||||
|
||||
class SonarrScanner
|
||||
extends BaseScanner<SonarrSeries>
|
||||
implements RunnableScanner<SyncStatus> {
|
||||
private servers: SonarrSettings[];
|
||||
private currentServer: SonarrSettings;
|
||||
private sonarrApi: SonarrAPI;
|
||||
|
||||
constructor() {
|
||||
super('Sonarr Scan', { bundleSize: 50 });
|
||||
}
|
||||
|
||||
public status(): SyncStatus {
|
||||
return {
|
||||
running: this.running,
|
||||
progress: this.progress,
|
||||
total: this.items.length,
|
||||
currentServer: this.currentServer,
|
||||
servers: this.servers,
|
||||
};
|
||||
}
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const settings = getSettings();
|
||||
const sessionId = this.startRun();
|
||||
|
||||
try {
|
||||
this.servers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => {
|
||||
return (
|
||||
sonarrA.hostname === sonarrB.hostname &&
|
||||
sonarrA.port === sonarrB.port &&
|
||||
sonarrA.baseUrl === sonarrB.baseUrl
|
||||
);
|
||||
});
|
||||
|
||||
for (const server of this.servers) {
|
||||
this.currentServer = server;
|
||||
if (server.syncEnabled) {
|
||||
this.log(
|
||||
`Beginning to process Sonarr server: ${server.name}`,
|
||||
'info'
|
||||
);
|
||||
|
||||
this.sonarrApi = new SonarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: SonarrAPI.buildSonarrUrl(server, '/api/v3'),
|
||||
});
|
||||
|
||||
this.items = await this.sonarrApi.getSeries();
|
||||
|
||||
await this.loop(this.processSonarrSeries.bind(this), { sessionId });
|
||||
} else {
|
||||
this.log(`Sync not enabled. Skipping Sonarr server: ${server.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.log('Sonarr scan complete', 'info');
|
||||
} catch (e) {
|
||||
this.log('Scan interrupted', 'error', { errorMessage: e.message });
|
||||
} finally {
|
||||
this.endRun(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private async processSonarrSeries(sonarrSeries: SonarrSeries) {
|
||||
try {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const server4k = this.enable4kShow && this.currentServer.is4k;
|
||||
const processableSeasons: ProcessableSeason[] = [];
|
||||
let tmdbId: number;
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { tvdbId: sonarrSeries.tvdbId },
|
||||
});
|
||||
|
||||
if (!media || !media.tmdbId) {
|
||||
const tvShow = await this.tmdb.getShowByTvdbId({
|
||||
tvdbId: sonarrSeries.tvdbId,
|
||||
});
|
||||
|
||||
tmdbId = tvShow.id;
|
||||
} else {
|
||||
tmdbId = media.tmdbId;
|
||||
}
|
||||
|
||||
const filteredSeasons = sonarrSeries.seasons.filter(
|
||||
(sn) => sn.seasonNumber !== 0
|
||||
);
|
||||
|
||||
for (const season of filteredSeasons) {
|
||||
const totalAvailableEpisodes = season.statistics?.episodeFileCount ?? 0;
|
||||
|
||||
processableSeasons.push({
|
||||
seasonNumber: season.seasonNumber,
|
||||
episodes: !server4k ? totalAvailableEpisodes : 0,
|
||||
episodes4k: server4k ? totalAvailableEpisodes : 0,
|
||||
totalEpisodes: season.statistics?.totalEpisodeCount ?? 0,
|
||||
processing: season.monitored && totalAvailableEpisodes === 0,
|
||||
is4kOverride: server4k,
|
||||
});
|
||||
}
|
||||
|
||||
await this.processShow(tmdbId, sonarrSeries.tvdbId, processableSeasons, {
|
||||
serviceId: this.currentServer.id,
|
||||
externalServiceId: sonarrSeries.id,
|
||||
externalServiceSlug: sonarrSeries.titleSlug,
|
||||
title: sonarrSeries.title,
|
||||
is4k: server4k,
|
||||
});
|
||||
} catch (e) {
|
||||
this.log('Failed to process Sonarr media', 'error', {
|
||||
errorMessage: e.message,
|
||||
title: sonarrSeries.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const sonarrScanner = new SonarrScanner();
|
||||
@@ -10,6 +10,17 @@ export interface Library {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface Region {
|
||||
iso_3166_1: string;
|
||||
english_name: string;
|
||||
}
|
||||
|
||||
export interface Language {
|
||||
iso_639_1: string;
|
||||
english_name: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PlexSettings {
|
||||
name: string;
|
||||
machineId?: string;
|
||||
@@ -32,6 +43,9 @@ interface DVRSettings {
|
||||
activeDirectory: string;
|
||||
is4k: boolean;
|
||||
isDefault: boolean;
|
||||
externalUrl?: string;
|
||||
syncEnabled: boolean;
|
||||
preventSearch: boolean;
|
||||
}
|
||||
|
||||
export interface RadarrSettings extends DVRSettings {
|
||||
@@ -42,13 +56,22 @@ export interface SonarrSettings extends DVRSettings {
|
||||
activeAnimeProfileId?: number;
|
||||
activeAnimeProfileName?: string;
|
||||
activeAnimeDirectory?: string;
|
||||
activeAnimeLanguageProfileId?: number;
|
||||
activeLanguageProfileId?: number;
|
||||
enableSeasonFolders: boolean;
|
||||
}
|
||||
|
||||
export interface MainSettings {
|
||||
apiKey: string;
|
||||
applicationTitle: string;
|
||||
applicationUrl: string;
|
||||
csrfProtection: boolean;
|
||||
defaultPermissions: number;
|
||||
hideAvailable: boolean;
|
||||
localLogin: boolean;
|
||||
region: string;
|
||||
originalLanguage: string;
|
||||
trustProxy: boolean;
|
||||
}
|
||||
|
||||
interface PublicSettings {
|
||||
@@ -56,8 +79,13 @@ interface PublicSettings {
|
||||
}
|
||||
|
||||
interface FullPublicSettings extends PublicSettings {
|
||||
applicationTitle: string;
|
||||
hideAvailable: boolean;
|
||||
localLogin: boolean;
|
||||
movie4kEnabled: boolean;
|
||||
series4kEnabled: boolean;
|
||||
region: string;
|
||||
originalLanguage: string;
|
||||
}
|
||||
|
||||
export interface NotificationAgentConfig {
|
||||
@@ -67,6 +95,8 @@ export interface NotificationAgentConfig {
|
||||
}
|
||||
export interface NotificationAgentDiscord extends NotificationAgentConfig {
|
||||
options: {
|
||||
botUsername?: string;
|
||||
botAvatarUrl?: string;
|
||||
webhookUrl: string;
|
||||
};
|
||||
}
|
||||
@@ -87,13 +117,23 @@ export interface NotificationAgentEmail extends NotificationAgentConfig {
|
||||
authPass?: string;
|
||||
allowSelfSigned: boolean;
|
||||
senderName: string;
|
||||
pgpPrivateKey?: string;
|
||||
pgpPassword?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NotificationAgentTelegram extends NotificationAgentConfig {
|
||||
options: {
|
||||
botUsername?: string;
|
||||
botAPI: string;
|
||||
chatId: string;
|
||||
sendSilently: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NotificationAgentPushbullet extends NotificationAgentConfig {
|
||||
options: {
|
||||
accessToken: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,7 +142,6 @@ export interface NotificationAgentPushover extends NotificationAgentConfig {
|
||||
accessToken: string;
|
||||
userToken: string;
|
||||
priority: number;
|
||||
sound: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,15 +154,17 @@ export interface NotificationAgentWebhook extends NotificationAgentConfig {
|
||||
}
|
||||
|
||||
interface NotificationAgents {
|
||||
email: NotificationAgentEmail;
|
||||
discord: NotificationAgentDiscord;
|
||||
email: NotificationAgentEmail;
|
||||
pushbullet: NotificationAgentPushbullet;
|
||||
pushover: NotificationAgentPushover;
|
||||
slack: NotificationAgentSlack;
|
||||
telegram: NotificationAgentTelegram;
|
||||
pushover: NotificationAgentPushover;
|
||||
webhook: NotificationAgentWebhook;
|
||||
}
|
||||
|
||||
interface NotificationSettings {
|
||||
enabled: boolean;
|
||||
agents: NotificationAgents;
|
||||
}
|
||||
|
||||
@@ -149,8 +190,15 @@ class Settings {
|
||||
clientId: uuidv4(),
|
||||
main: {
|
||||
apiKey: '',
|
||||
applicationTitle: 'Overseerr',
|
||||
applicationUrl: '',
|
||||
csrfProtection: false,
|
||||
defaultPermissions: Permission.REQUEST,
|
||||
hideAvailable: false,
|
||||
localLogin: true,
|
||||
region: '',
|
||||
originalLanguage: '',
|
||||
trustProxy: false,
|
||||
},
|
||||
plex: {
|
||||
name: '',
|
||||
@@ -165,6 +213,7 @@ class Settings {
|
||||
initialized: false,
|
||||
},
|
||||
notifications: {
|
||||
enabled: true,
|
||||
agents: {
|
||||
email: {
|
||||
enabled: false,
|
||||
@@ -182,6 +231,8 @@ class Settings {
|
||||
enabled: false,
|
||||
types: 0,
|
||||
options: {
|
||||
botUsername: '',
|
||||
botAvatarUrl: '',
|
||||
webhookUrl: '',
|
||||
},
|
||||
},
|
||||
@@ -196,8 +247,17 @@ class Settings {
|
||||
enabled: false,
|
||||
types: 0,
|
||||
options: {
|
||||
botUsername: '',
|
||||
botAPI: '',
|
||||
chatId: '',
|
||||
sendSilently: false,
|
||||
},
|
||||
},
|
||||
pushbullet: {
|
||||
enabled: false,
|
||||
types: 0,
|
||||
options: {
|
||||
accessToken: '',
|
||||
},
|
||||
},
|
||||
pushover: {
|
||||
@@ -207,7 +267,6 @@ class Settings {
|
||||
accessToken: '',
|
||||
userToken: '',
|
||||
priority: 0,
|
||||
sound: '',
|
||||
},
|
||||
},
|
||||
webhook: {
|
||||
@@ -275,12 +334,17 @@ class Settings {
|
||||
get fullPublicSettings(): FullPublicSettings {
|
||||
return {
|
||||
...this.data.public,
|
||||
applicationTitle: this.data.main.applicationTitle,
|
||||
hideAvailable: this.data.main.hideAvailable,
|
||||
localLogin: this.data.main.localLogin,
|
||||
movie4kEnabled: this.data.radarr.some(
|
||||
(radarr) => radarr.is4k && radarr.isDefault
|
||||
),
|
||||
series4kEnabled: this.data.sonarr.some(
|
||||
(sonarr) => sonarr.is4k && sonarr.isDefault
|
||||
),
|
||||
region: this.data.main.region,
|
||||
originalLanguage: this.data.main.originalLanguage,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getRepository } from 'typeorm';
|
||||
import { User } from '../entity/User';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import { Permission, PermissionCheckOptions } from '../lib/permissions';
|
||||
import { getSettings } from '../lib/settings';
|
||||
|
||||
export const checkUser: Middleware = async (req, _res, next) => {
|
||||
@@ -34,10 +34,11 @@ export const checkUser: Middleware = async (req, _res, next) => {
|
||||
};
|
||||
|
||||
export const isAuthenticated = (
|
||||
permissions?: Permission | Permission[]
|
||||
permissions?: Permission | Permission[],
|
||||
options?: PermissionCheckOptions
|
||||
): Middleware => {
|
||||
const authMiddleware: Middleware = (req, res, next) => {
|
||||
if (!req.user || !req.user.hasPermission(permissions ?? 0)) {
|
||||
if (!req.user || !req.user.hasPermission(permissions ?? 0, options)) {
|
||||
res.status(403).json({
|
||||
status: 403,
|
||||
error: 'You do not have permission to access this endpoint',
|
||||
|
||||
43
server/migration/1611508672722-AddDisplayNameToUser.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddDisplayNameToUser1611508672722 implements MigrationInterface {
|
||||
name = 'AddDisplayNameToUser1611508672722';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "username" FROM "user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername" FROM "user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername" FROM "temporary_user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user"`);
|
||||
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType" FROM "temporary_user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class SonarrRadarrSyncServiceFields1611757511674
|
||||
implements MigrationInterface {
|
||||
name = 'SonarrRadarrSyncServiceFields1611757511674';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt" FROM "media"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "media"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
|
||||
await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt" FROM "temporary_media"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||
);
|
||||
}
|
||||
}
|
||||
51
server/migration/1611801511397-AddRatingKeysToMedia.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddRatingKeysToMedia1611801511397 implements MigrationInterface {
|
||||
name = 'AddRatingKeysToMedia1611801511397';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k" FROM "media"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "media"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
|
||||
await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k" FROM "temporary_media"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddResetPasswordGuidAndExpiryDate1612482778137
|
||||
implements MigrationInterface {
|
||||
name = 'AddResetPasswordGuidAndExpiryDate1612482778137';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername" FROM "user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername" FROM "temporary_user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user"`);
|
||||
}
|
||||
}
|
||||
31
server/migration/1612571545781-AddLanguageProfileId.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddLanguageProfileId1612571545781 implements MigrationInterface {
|
||||
name = 'AddLanguageProfileId1612571545781';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder" FROM "media_request"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "media_request"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_media_request" RENAME TO "media_request"`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "media_request" RENAME TO "temporary_media_request"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder" FROM "temporary_media_request"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_media_request"`);
|
||||
}
|
||||
}
|
||||
35
server/migration/1613615266968-CreateUserSettings.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateUserSettings1613615266968 implements MigrationInterface {
|
||||
name = 'CreateUserSettings1613615266968';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user_settings"("id", "enableNotifications", "discordId", "userId") SELECT "id", "enableNotifications", "discordId", "userId" FROM "user_settings"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user_settings"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user_settings"("id", "enableNotifications", "discordId", "userId") SELECT "id", "enableNotifications", "discordId", "userId" FROM "temporary_user_settings"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
|
||||
await queryRunner.query(`DROP TABLE "user_settings"`);
|
||||
}
|
||||
}
|
||||