Compare commits
241 Commits
preview-no
...
preview-OI
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f39794116 | ||
|
|
be5ee1e980 | ||
|
|
ab3c8e4e7e | ||
|
|
05b0a1fa99 | ||
|
|
cfbf7edf7e | ||
|
|
8f8a4153b6 | ||
|
|
f4988aba15 | ||
|
|
4a3f38fa10 | ||
|
|
67bbc73564 | ||
|
|
205aa5da92 | ||
|
|
27292c02ea | ||
|
|
80217c70ea | ||
|
|
7d426f0c7e | ||
|
|
55870fed20 | ||
|
|
7bd26eebb5 | ||
|
|
d36dcbed7e | ||
|
|
03d869ca59 | ||
|
|
51849cd9de | ||
|
|
fa7efa31bc | ||
|
|
4878722030 | ||
|
|
479be0daeb | ||
|
|
6245dae3b3 | ||
|
|
d82c6f6222 | ||
|
|
13fe4c890b | ||
|
|
22b2824441 | ||
|
|
368ecf8771 | ||
|
|
d3fd5028dc | ||
|
|
c0fd81a5f0 | ||
|
|
af7ceaf7a2 | ||
|
|
b4adfd2ffa | ||
|
|
5c1583cf56 | ||
|
|
66d4cd63bb | ||
|
|
e8ec3473da | ||
|
|
17d4f13afe | ||
|
|
3292f11308 | ||
|
|
c86ee0ddb1 | ||
|
|
e02ee24f70 | ||
|
|
ca1686425b | ||
|
|
e52c63164f | ||
|
|
e98f31e66c | ||
|
|
75a7279ea2 | ||
|
|
d53ffca5db | ||
|
|
844b1abad9 | ||
|
|
c88a20f536 | ||
|
|
4c633d49c5 | ||
|
|
6be0c92d7b | ||
|
|
3be920e74b | ||
|
|
2e64f1344e | ||
|
|
8f9bc5f761 | ||
|
|
d0bd134d88 | ||
|
|
510108f9bb | ||
|
|
8c43db2abf | ||
|
|
b83367cbf2 | ||
|
|
0fd03f3848 | ||
|
|
9cb7e1495a | ||
|
|
0357d17205 | ||
|
|
049bc59d2d | ||
|
|
7c969f4235 | ||
|
|
bb95c7009f | ||
|
|
d4a6cb268a | ||
|
|
fb8677f29c | ||
|
|
c7284f473c | ||
|
|
0bdc8a0334 | ||
|
|
c0dd2e5e27 | ||
|
|
6b8c0bd8f3 | ||
|
|
ea7e68fc99 | ||
|
|
110adfaf66 | ||
|
|
515124bab4 | ||
|
|
52e85e1404 | ||
|
|
24e1e94747 | ||
|
|
b27dbd7a15 | ||
|
|
78afd02d3d | ||
|
|
8949edea7e | ||
|
|
e69649d71d | ||
|
|
d226dbb9b4 | ||
|
|
8da1c92923 | ||
|
|
70a28dd1e3 | ||
|
|
c067a03531 | ||
|
|
437bf0f4ee | ||
|
|
45f25408c6 | ||
|
|
123894b475 | ||
|
|
6b9aedb970 | ||
|
|
1651725665 | ||
|
|
cf9c33d124 | ||
|
|
e8f1edc062 | ||
|
|
d01f9a0580 | ||
|
|
c55da3da5f | ||
|
|
a19dcaf5e5 | ||
|
|
1870e637e4 | ||
|
|
185167a0a7 | ||
|
|
2e4d14698f | ||
|
|
149d79e540 | ||
|
|
e5b77b2688 | ||
|
|
fc4db7fa00 | ||
|
|
48dea32bd0 | ||
|
|
806cd9013a | ||
|
|
8a42fe16b5 | ||
|
|
236d431fa3 | ||
|
|
5fd65eb1ba | ||
|
|
c3b8574515 | ||
|
|
7c2444a65f | ||
|
|
355b76de5c | ||
|
|
b7b05d31a5 | ||
|
|
f3a895fa7d | ||
|
|
4a5ac3cc42 | ||
|
|
a488f850f3 | ||
|
|
21400cecdc | ||
|
|
5a6ff61f64 | ||
|
|
14ee52e93e | ||
|
|
4cf799d6eb | ||
|
|
bea57c330a | ||
|
|
7d36dc182b | ||
|
|
5865478a3b | ||
|
|
90c58de9b2 | ||
|
|
2f6be955b5 | ||
|
|
85bbc85714 | ||
|
|
8dc1d8196c | ||
|
|
63dc27d400 | ||
|
|
29034b350d | ||
|
|
7438042757 | ||
|
|
0b0b76e58c | ||
|
|
a5cb505609 | ||
|
|
7cb127ec3f | ||
|
|
1635932375 | ||
|
|
c1aeab9538 | ||
|
|
70fb1f2b00 | ||
|
|
4cd02babba | ||
|
|
f5b3a526cb | ||
|
|
e5ab847547 | ||
|
|
40539cc4b1 | ||
|
|
0bd6d57834 | ||
|
|
f884ac9c66 | ||
|
|
c2d9d00b41 | ||
|
|
77a36f9714 | ||
|
|
f773e0fb2a | ||
|
|
767a24164d | ||
|
|
8394eb5ad4 | ||
|
|
b8425d6388 | ||
|
|
ebb7f00305 | ||
|
|
418d51590d | ||
|
|
a6dd4a8fed | ||
|
|
4d1163c343 | ||
|
|
b085e12ff9 | ||
|
|
33e7a153aa | ||
|
|
9891a7577c | ||
|
|
077e355c77 | ||
|
|
21ab20bba9 | ||
|
|
cdfb30ea16 | ||
|
|
771ecdf781 | ||
|
|
863b675c77 | ||
|
|
5b998bef82 | ||
|
|
0113612ced | ||
|
|
f8c9689745 | ||
|
|
af8d6b475c | ||
|
|
dcc13080bc | ||
|
|
e97a13e1e4 | ||
|
|
1de518d915 | ||
|
|
4e44282387 | ||
|
|
67bd639a43 | ||
|
|
ada467ecf4 | ||
|
|
9cc6930fed | ||
|
|
3b4d6bf5b8 | ||
|
|
07e4662205 | ||
|
|
4eddbaa71b | ||
|
|
27112be933 | ||
|
|
a790b1abcc | ||
|
|
f0a6055774 | ||
|
|
a3f4773a35 | ||
|
|
73d8efaa54 | ||
|
|
9712f56054 | ||
|
|
b1f07f0eb2 | ||
|
|
64f05bcad6 | ||
|
|
80927b9705 | ||
|
|
d563b36186 | ||
|
|
117617188e | ||
|
|
525a538f34 | ||
|
|
0d2273ff6e | ||
|
|
e035cd84ae | ||
|
|
438ccfe9c3 | ||
|
|
c181cee328 | ||
|
|
98a5b05816 | ||
|
|
b29959b063 | ||
|
|
9a2c12e51c | ||
|
|
620135aeac | ||
|
|
2dbd1096d2 | ||
|
|
24d3f523fc | ||
|
|
2b7974fa06 | ||
|
|
907ba6fdea | ||
|
|
efaad21554 | ||
|
|
6ab463285d | ||
|
|
418f0c2eb8 | ||
|
|
002557d2d0 | ||
|
|
62c1a70b37 | ||
|
|
1b325e7c32 | ||
|
|
f247642b76 | ||
|
|
396cd968ef | ||
|
|
ca739315b2 | ||
|
|
9143a6c027 | ||
|
|
d7fc03650f | ||
|
|
80fc5c1a78 | ||
|
|
95737d36e6 | ||
|
|
0fd6ca85a4 | ||
|
|
7cee9b475d | ||
|
|
ff9af866f8 | ||
|
|
5ffe6419ee | ||
|
|
8afcf5a8d8 | ||
|
|
17d93a8cb9 | ||
|
|
549082c53e | ||
|
|
fbef7e2c72 | ||
|
|
93d2e26ae9 | ||
|
|
f09a432635 | ||
|
|
a8f84d4f74 | ||
|
|
88e96fa163 | ||
|
|
2d814c1416 | ||
|
|
2f4b848b2c | ||
|
|
0ee3e69a61 | ||
|
|
7fcc0eb66d | ||
|
|
5d9f613dd8 | ||
|
|
ae1ee777fa | ||
|
|
b9dc9bceb5 | ||
|
|
1ffcea2065 | ||
|
|
ce1b39f73b | ||
|
|
71143ca76b | ||
|
|
bebad2d814 | ||
|
|
1c6f5362d7 | ||
|
|
4f6192476b | ||
|
|
e97b3dfa9e | ||
|
|
221397db71 | ||
|
|
9ecbd98230 | ||
|
|
3cc34b0db6 | ||
|
|
5a323324f8 | ||
|
|
99e2e17b8a | ||
|
|
0f14cd9247 | ||
|
|
50db3ea27b | ||
|
|
131a5a2b0b | ||
|
|
7d08f58c76 | ||
|
|
425f0c854e | ||
|
|
b8dbfaaed0 | ||
|
|
24c6208a3c | ||
|
|
d71ee58302 | ||
|
|
1755877e66 |
@@ -7,7 +7,7 @@
|
|||||||
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
||||||
"contributorsPerLine": 7,
|
"contributorsPerLine": 7,
|
||||||
"projectName": "jellyseerr",
|
"projectName": "jellyseerr",
|
||||||
"projectOwner": "Fallenbagel",
|
"projectOwner": "fallenbagel",
|
||||||
"repoType": "github",
|
"repoType": "github",
|
||||||
"repoHost": "https://github.com",
|
"repoHost": "https://github.com",
|
||||||
"skipCi": true,
|
"skipCi": true,
|
||||||
@@ -94,7 +94,8 @@
|
|||||||
"avatar_url": "https://avatars.githubusercontent.com/u/345752?v=4",
|
"avatar_url": "https://avatars.githubusercontent.com/u/345752?v=4",
|
||||||
"profile": "https://github.com/jab416171",
|
"profile": "https://github.com/jab416171",
|
||||||
"contributions": [
|
"contributions": [
|
||||||
"doc"
|
"doc",
|
||||||
|
"code"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -248,7 +249,8 @@
|
|||||||
"avatar_url": "https://avatars.githubusercontent.com/u/854646?v=4",
|
"avatar_url": "https://avatars.githubusercontent.com/u/854646?v=4",
|
||||||
"profile": "http://www.piribisoft.com",
|
"profile": "http://www.piribisoft.com",
|
||||||
"contributions": [
|
"contributions": [
|
||||||
"doc"
|
"doc",
|
||||||
|
"code"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -275,7 +277,8 @@
|
|||||||
"avatar_url": "https://avatars.githubusercontent.com/u/13810742?v=4",
|
"avatar_url": "https://avatars.githubusercontent.com/u/13810742?v=4",
|
||||||
"profile": "https://athfan.com",
|
"profile": "https://athfan.com",
|
||||||
"contributions": [
|
"contributions": [
|
||||||
"doc"
|
"doc",
|
||||||
|
"code"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -293,7 +296,8 @@
|
|||||||
"avatar_url": "https://avatars.githubusercontent.com/u/13354331?v=4",
|
"avatar_url": "https://avatars.githubusercontent.com/u/13354331?v=4",
|
||||||
"profile": "https://github.com/xeruf",
|
"profile": "https://github.com/xeruf",
|
||||||
"contributions": [
|
"contributions": [
|
||||||
"doc"
|
"doc",
|
||||||
|
"code"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -338,7 +342,8 @@
|
|||||||
"avatar_url": "https://avatars.githubusercontent.com/u/37781713?v=4",
|
"avatar_url": "https://avatars.githubusercontent.com/u/37781713?v=4",
|
||||||
"profile": "https://gauthierth.fr/",
|
"profile": "https://gauthierth.fr/",
|
||||||
"contributions": [
|
"contributions": [
|
||||||
"code"
|
"code",
|
||||||
|
"maintenance"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -377,33 +382,6 @@
|
|||||||
"code"
|
"code"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"login": "j0srisk",
|
|
||||||
"name": "Joseph Risk",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4",
|
|
||||||
"profile": "http://josephrisk.com",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "Loetwiek",
|
|
||||||
"name": "Loetwiek",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4",
|
|
||||||
"profile": "https://github.com/Loetwiek",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "Fuochi",
|
|
||||||
"name": "Fuochi",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4",
|
|
||||||
"profile": "https://github.com/Fuochi",
|
|
||||||
"contributions": [
|
|
||||||
"doc"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"login": "mobihen",
|
"login": "mobihen",
|
||||||
"name": "Nir Israel Hen",
|
"name": "Nir Israel Hen",
|
||||||
@@ -449,69 +427,6 @@
|
|||||||
"security"
|
"security"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"login": "j0srisk",
|
|
||||||
"name": "Joseph Risk",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4",
|
|
||||||
"profile": "http://josephrisk.com",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "Loetwiek",
|
|
||||||
"name": "Loetwiek",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4",
|
|
||||||
"profile": "https://github.com/Loetwiek",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "Fuochi",
|
|
||||||
"name": "Fuochi",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4",
|
|
||||||
"profile": "https://github.com/Fuochi",
|
|
||||||
"contributions": [
|
|
||||||
"doc"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "demrich",
|
|
||||||
"name": "David Emrich",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/30092389?v=4",
|
|
||||||
"profile": "https://github.com/demrich",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "maxnatamo",
|
|
||||||
"name": "Max T. Kristiansen",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/5898152?v=4",
|
|
||||||
"profile": "https://maxtrier.dk",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "DamsDev1",
|
|
||||||
"name": "Damien Fajole",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/60252259?v=4",
|
|
||||||
"profile": "https://damsdev.me",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "AhmedNSidd",
|
|
||||||
"name": "Ahmed Siddiqui",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/36286128?v=4",
|
|
||||||
"profile": "https://github.com/AhmedNSidd",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"login": "Zariel",
|
"login": "Zariel",
|
||||||
"name": "Chris Bannister",
|
"name": "Chris Bannister",
|
||||||
@@ -556,6 +471,177 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"code"
|
"code"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "GkhnGRBZ",
|
||||||
|
"name": "GkhnGRBZ",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/127258824?v=4",
|
||||||
|
"profile": "https://github.com/GkhnGRBZ",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "benhaney",
|
||||||
|
"name": "Ben Haney",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/31331498?v=4",
|
||||||
|
"profile": "http://benhaney.com",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Wunderharke",
|
||||||
|
"name": "Wunderharke",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/5105672?v=4",
|
||||||
|
"profile": "https://github.com/Wunderharke",
|
||||||
|
"contributions": [
|
||||||
|
"doc"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "methbkts",
|
||||||
|
"name": "Metin Bektas",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/30674934?v=4",
|
||||||
|
"profile": "https://github.com/methbkts",
|
||||||
|
"contributions": [
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "andrewkolda",
|
||||||
|
"name": "andrewkolda",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/158614532?v=4",
|
||||||
|
"profile": "https://github.com/andrewkolda",
|
||||||
|
"contributions": [
|
||||||
|
"design"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "ishanjain28",
|
||||||
|
"name": "Ishan Jain",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/7921368?v=4",
|
||||||
|
"profile": "https://ishanjain.me",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "michaelhthomas",
|
||||||
|
"name": "Michael Thomas",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/18223295?v=4",
|
||||||
|
"profile": "http://michaelt.xyz",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "RankWeis",
|
||||||
|
"name": "RankWeis",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/733691?v=4",
|
||||||
|
"profile": "https://github.com/RankWeis",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "jessielw",
|
||||||
|
"name": "Jessie Wilson",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/48299282?v=4",
|
||||||
|
"profile": "http://www.linkedin.com/in/jessielwilson",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "brotaxt",
|
||||||
|
"name": "DominicKo",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/25477935?v=4",
|
||||||
|
"profile": "https://github.com/brotaxt",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "corentinnormand",
|
||||||
|
"name": "Corentin Normand",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/30508927?v=4",
|
||||||
|
"profile": "https://doctolib.com",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "benbeauchamp7",
|
||||||
|
"name": "Ben Beauchamp",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/43358492?v=4",
|
||||||
|
"profile": "https://github.com/benbeauchamp7",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "vfaergestad",
|
||||||
|
"name": "vfaergestad",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/49147564?v=4",
|
||||||
|
"profile": "https://github.com/vfaergestad",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "wolffman122",
|
||||||
|
"name": "wolffman122",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/19178872?v=4",
|
||||||
|
"profile": "https://github.com/wolffman122",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Schrottfresser",
|
||||||
|
"name": "Schrottfresser",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/39998368?v=4",
|
||||||
|
"profile": "https://github.com/Schrottfresser",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "DillionLowry",
|
||||||
|
"name": "Dillion",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/91228469?v=4",
|
||||||
|
"profile": "https://github.com/DillionLowry",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "JamsRepos",
|
||||||
|
"name": "Jam",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/1347620?v=4",
|
||||||
|
"profile": "https://github.com/JamsRepos",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "joelowrance",
|
||||||
|
"name": "Joe Lowrance",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/63176?v=4",
|
||||||
|
"profile": "http://www.joelowrance.com",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "0xSysR3ll",
|
||||||
|
"name": "0xsysr3ll",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/31414959?v=4",
|
||||||
|
"profile": "https://github.com/0xSysR3ll",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -63,6 +63,8 @@ body:
|
|||||||
- PostgreSQL
|
- PostgreSQL
|
||||||
label: Database
|
label: Database
|
||||||
description: Which database backend are you using?
|
description: Which database backend are you using?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: input
|
- type: input
|
||||||
id: device
|
id: device
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
89
.github/workflows/ci.yml
vendored
@@ -12,8 +12,8 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
name: Lint & Test Build
|
name: Lint & Test Build
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-24.04
|
||||||
container: node:20-alpine
|
container: node:22-alpine
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -43,15 +43,23 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
build_and_push:
|
build:
|
||||||
name: Build & Publish Docker Images
|
name: Build & Publish Docker Images
|
||||||
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, '[skip ci]')
|
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, '[skip ci]')
|
||||||
runs-on: ubuntu-22.04
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- runner: ubuntu-24.04
|
||||||
|
platform: linux/amd64
|
||||||
|
- runner: ubuntu-24.04-arm
|
||||||
|
platform: linux/arm64
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
outputs:
|
||||||
|
digest-amd64: ${{ steps.set_outputs.outputs.digest-amd64 }}
|
||||||
|
digest-arm64: ${{ steps.set_outputs.outputs.digest-arm64 }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
@@ -70,24 +78,79 @@ jobs:
|
|||||||
echo "OWNER_LC=${OWNER,,}" >>${GITHUB_ENV}
|
echo "OWNER_LC=${OWNER,,}" >>${GITHUB_ENV}
|
||||||
env:
|
env:
|
||||||
OWNER: ${{ github.repository_owner }}
|
OWNER: ${{ github.repository_owner }}
|
||||||
- name: Build and push
|
- name: Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
fallenbagel/jellyseerr
|
||||||
|
ghcr.io/${{ env.OWNER_LC }}/jellyseerr
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=sha,prefix=,suffix=,format=short
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: ${{ matrix.platform }}
|
||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
COMMIT_TAG=${{ github.sha }}
|
COMMIT_TAG=${{ github.sha }}
|
||||||
tags: |
|
BUILD_VERSION=develop
|
||||||
fallenbagel/jellyseerr:develop
|
BUILD_DATE=${{ github.event.repository.updated_at }}
|
||||||
ghcr.io/${{ env.OWNER_LC }}/jellyseerr:develop
|
outputs: |
|
||||||
|
type=image,push-by-digest=true,name=fallenbagel/jellyseerr,push=true
|
||||||
|
type=image,push-by-digest=true,name=ghcr.io/${{ env.OWNER_LC }}/jellyseerr,push=true
|
||||||
|
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||||
|
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||||
|
provenance: false
|
||||||
|
- name: Set outputs
|
||||||
|
id: set_outputs
|
||||||
|
run: |
|
||||||
|
platform="${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}"
|
||||||
|
echo "digest-${platform}=${{ steps.build.outputs.digest }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
merge_and_push:
|
||||||
|
name: Create and Push Multi-arch Manifest
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Set lower case owner name
|
||||||
|
run: |
|
||||||
|
echo "OWNER_LC=${OWNER,,}" >>${GITHUB_ENV}
|
||||||
|
env:
|
||||||
|
OWNER: ${{ github.repository_owner }}
|
||||||
|
- name: Create and push manifest
|
||||||
|
run: |
|
||||||
|
docker manifest create fallenbagel/jellyseerr:develop \
|
||||||
|
--amend fallenbagel/jellyseerr@${{ needs.build.outputs.digest-amd64 }} \
|
||||||
|
--amend fallenbagel/jellyseerr@${{ needs.build.outputs.digest-arm64 }}
|
||||||
|
docker manifest push fallenbagel/jellyseerr:develop
|
||||||
|
|
||||||
|
# GHCR manifest
|
||||||
|
docker manifest create ghcr.io/${{ env.OWNER_LC }}/jellyseerr:develop \
|
||||||
|
--amend ghcr.io/${{ env.OWNER_LC }}/jellyseerr@${{ needs.build.outputs.digest-amd64 }} \
|
||||||
|
--amend ghcr.io/${{ env.OWNER_LC }}/jellyseerr@${{ needs.build.outputs.digest-arm64 }}
|
||||||
|
docker manifest push ghcr.io/${{ env.OWNER_LC }}/jellyseerr:develop
|
||||||
|
|
||||||
discord:
|
discord:
|
||||||
name: Send Discord Notification
|
name: Send Discord Notification
|
||||||
needs: build_and_push
|
needs: merge_and_push
|
||||||
if: always() && github.event_name != 'pull_request' && !contains(github.event.head_commit.message, '[skip ci]')
|
if: always() && github.event_name != 'pull_request' && !contains(github.event.head_commit.message, '[skip ci]')
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Get Build Job Status
|
- name: Get Build Job Status
|
||||||
uses: technote-space/workflow-conclusion-action@v3
|
uses: technote-space/workflow-conclusion-action@v3
|
||||||
|
|||||||
10
.github/workflows/cypress.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
|||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
- name: Pnpm Setup
|
- name: Pnpm Setup
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
@@ -36,3 +36,11 @@ jobs:
|
|||||||
# Fix test titles in cypress dashboard
|
# Fix test titles in cypress dashboard
|
||||||
COMMIT_INFO_MESSAGE: ${{github.event.pull_request.title}}
|
COMMIT_INFO_MESSAGE: ${{github.event.pull_request.title}}
|
||||||
COMMIT_INFO_SHA: ${{github.event.pull_request.head.sha}}
|
COMMIT_INFO_SHA: ${{github.event.pull_request.head.sha}}
|
||||||
|
- name: Upload video files
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: cypress-videos
|
||||||
|
path: |
|
||||||
|
cypress/videos
|
||||||
|
cypress/screenshots
|
||||||
|
|||||||
135
.github/workflows/helm.yml
vendored
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
name: Release Charts
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
package-helm-chart:
|
||||||
|
name: Package helm chart
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: read
|
||||||
|
outputs:
|
||||||
|
has_artifacts: ${{ steps.check-artifacts.outputs.has_artifacts }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install helm
|
||||||
|
uses: azure/setup-helm@v4
|
||||||
|
|
||||||
|
- name: Install Oras
|
||||||
|
uses: oras-project/setup-oras@v1
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Package helm charts
|
||||||
|
run: |
|
||||||
|
mkdir -p ./.cr-release-packages
|
||||||
|
for chart_path in ./charts/*; do
|
||||||
|
if [ -d "$chart_path" ] && [ -f "$chart_path/Chart.yaml" ]; then
|
||||||
|
chart_name=$(grep '^name:' "$chart_path/Chart.yaml" | awk '{print $2}')
|
||||||
|
# get current version
|
||||||
|
current_version=$(grep '^version:' "$chart_path/Chart.yaml" | awk '{print $2}')
|
||||||
|
# try to get current release version
|
||||||
|
set +e
|
||||||
|
oras discover ghcr.io/${GITHUB_REPOSITORY@L}/${chart_name}:${current_version}
|
||||||
|
oras_exit_code=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ $oras_exit_code -ne 0 ]; then
|
||||||
|
helm dependency build "$chart_path"
|
||||||
|
helm package "$chart_path" --destination ./.cr-release-packages
|
||||||
|
else
|
||||||
|
echo "No version change for $chart_name. Skipping."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Skipping $chart_name: Not a valid Helm chart"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Check if artifacts exist
|
||||||
|
id: check-artifacts
|
||||||
|
run: |
|
||||||
|
if ls .cr-release-packages/* >/dev/null 2>&1; then
|
||||||
|
echo "has_artifacts=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "has_artifacts=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: steps.check-artifacts.outputs.has_artifacts == 'true'
|
||||||
|
with:
|
||||||
|
name: artifacts
|
||||||
|
include-hidden-files: true
|
||||||
|
path: .cr-release-packages/
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish to ghcr.io
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: write # needed for pushing to github registry
|
||||||
|
id-token: write # needed for signing the images with GitHub OIDC Token
|
||||||
|
needs: [package-helm-chart]
|
||||||
|
if: needs.package-helm-chart.outputs.has_artifacts == 'true'
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install helm
|
||||||
|
uses: azure/setup-helm@v4
|
||||||
|
|
||||||
|
- name: Install Oras
|
||||||
|
uses: oras-project/setup-oras@v1
|
||||||
|
|
||||||
|
- name: Install Cosign
|
||||||
|
uses: sigstore/cosign-installer@v3
|
||||||
|
|
||||||
|
- name: Downloads artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: artifacts
|
||||||
|
path: .cr-release-packages/
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Push charts to GHCR
|
||||||
|
env:
|
||||||
|
COSIGN_YES: true
|
||||||
|
run: |
|
||||||
|
for chart_path in `find .cr-release-packages -name '*.tgz' -print`; do
|
||||||
|
# push chart to OCI
|
||||||
|
chart_release_file=$(basename "$chart_path")
|
||||||
|
chart_name=${chart_release_file%-*}
|
||||||
|
helm push ${chart_path} oci://ghcr.io/${GITHUB_REPOSITORY@L} |& tee helm-push-output.log
|
||||||
|
chart_digest=$(awk -F "[, ]+" '/Digest/{print $NF}' < helm-push-output.log)
|
||||||
|
# sign chart
|
||||||
|
cosign sign "ghcr.io/${GITHUB_REPOSITORY@L}/${chart_name}@${chart_digest}"
|
||||||
|
# push artifacthub-repo.yml to OCI
|
||||||
|
oras push \
|
||||||
|
ghcr.io/${GITHUB_REPOSITORY@L}/${chart_name}:artifacthub.io \
|
||||||
|
--config /dev/null:application/vnd.cncf.artifacthub.config.v1+yaml \
|
||||||
|
charts/$chart_name/artifacthub-repo.yml:application/vnd.cncf.artifacthub.repository-metadata.layer.v1.yaml \
|
||||||
|
|& tee oras-push-output.log
|
||||||
|
artifacthub_digest=$(grep "Digest:" oras-push-output.log | awk '{print $2}')
|
||||||
|
# sign artifacthub-repo.yml
|
||||||
|
cosign sign "ghcr.io/${GITHUB_REPOSITORY@L}/${chart_name}:artifacthub.io@${artifacthub_digest}"
|
||||||
|
done
|
||||||
2
.github/workflows/preview.yml
vendored
@@ -33,5 +33,7 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
COMMIT_TAG=${{ github.sha }}
|
COMMIT_TAG=${{ github.sha }}
|
||||||
|
BUILD_VERSION=${{ steps.get_version.outputs.VERSION }}
|
||||||
|
BUILD_DATE=${{ github.event.repository.updated_at }}
|
||||||
tags: |
|
tags: |
|
||||||
fallenbagel/jellyseerr:${{ steps.get_version.outputs.VERSION }}
|
fallenbagel/jellyseerr:${{ steps.get_version.outputs.VERSION }}
|
||||||
|
|||||||
8
.github/workflows/release.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@@ -26,6 +26,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GH_TOKEN }}
|
||||||
- name: Pnpm Setup
|
- name: Pnpm Setup
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ dist/
|
|||||||
config/
|
config/
|
||||||
CHANGELOG.md
|
CHANGELOG.md
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
cypress/config/settings.cypress.json
|
||||||
|
|
||||||
# assets
|
# assets
|
||||||
src/assets/
|
src/assets/
|
||||||
|
|||||||
@@ -21,5 +21,11 @@ module.exports = {
|
|||||||
rangeEnd: 0, // default: Infinity
|
rangeEnd: 0, // default: Infinity
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: 'cypress/config/settings.cypress.json',
|
||||||
|
options: {
|
||||||
|
rangeEnd: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
@@ -19,5 +19,6 @@
|
|||||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||||
"files.associations": {
|
"files.associations": {
|
||||||
"globals.css": "tailwindcss"
|
"globals.css": "tailwindcss"
|
||||||
}
|
},
|
||||||
|
"i18n-ally.localesPaths": ["src/i18n/locale"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
|
|||||||
|
|
||||||
- HTML/Typescript/Javascript editor
|
- HTML/Typescript/Javascript editor
|
||||||
- [VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install.
|
- [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 20.x)
|
- [NodeJS](https://nodejs.org/en/download/) (Node 22.x)
|
||||||
- [Pnpm](https://pnpm.io/cli/install)
|
- [Pnpm](https://pnpm.io/cli/install)
|
||||||
- [Git](https://git-scm.com/downloads)
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
|
||||||
@@ -58,12 +58,27 @@ All help is welcome and greatly appreciated! If you would like to contribute to
|
|||||||
|
|
||||||
- Be sure to follow both the [code](#contributing-code) and [UI text](#ui-text-style) guidelines.
|
- 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`:
|
- Should you need to update your fork, you can do so by rebasing from `upstream`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git fetch upstream
|
git fetch upstream
|
||||||
git rebase upstream/develop
|
git rebase upstream/develop
|
||||||
git push origin BRANCH_NAME -f
|
git push origin BRANCH_NAME -f
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Helm Chart
|
||||||
|
|
||||||
|
Tools Required:
|
||||||
|
|
||||||
|
- [Helm](https://helm.sh/docs/intro/install/)
|
||||||
|
- [helm-docs](https://github.com/norwoodj/helm-docs)
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
|
||||||
|
1. Make the necessary changes.
|
||||||
|
2. Test your changes.
|
||||||
|
3. Update the `version` in `charts/jellyseerr-chart/Chart.yaml` following [Semantic Versioning (SemVer)](https://semver.org/).
|
||||||
|
4. Run the `helm-docs` command to regenerate the chart's README.
|
||||||
|
|
||||||
### Contributing Code
|
### Contributing Code
|
||||||
|
|
||||||
- If you are taking on an existing bug or feature ticket, please comment on the [issue](https://github.com/fallenbagel/jellyseerr/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/fallenbagel/jellyseerr/issues) to avoid multiple people working on the same thing.
|
||||||
@@ -97,7 +112,7 @@ When adding new UI text, please try to adhere to the following guidelines:
|
|||||||
|
|
||||||
## Translation
|
## Translation
|
||||||
|
|
||||||
We use [Weblate](https://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/) 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/fallenbagel/jellyseerr/issues/new/choose).
|
We use [Weblate](https://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/) for our translations, and your help with localizing Jellyseerr would be greatly appreciated! If your language is not listed below, please [open a feature request](https://github.com/fallenbagel/jellyseerr/issues/new/choose).
|
||||||
|
|
||||||
<a href="https://jellyseerr.borgcube.de/engage/jellysseerr/"><img src="https://jellyseerr.borgcube.de/widget/jellyseerr/multi-auto.svg" alt="Translation status" /></a>
|
<a href="https://jellyseerr.borgcube.de/engage/jellysseerr/"><img src="https://jellyseerr.borgcube.de/widget/jellyseerr/multi-auto.svg" alt="Translation status" /></a>
|
||||||
|
|
||||||
|
|||||||
23
Dockerfile
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine AS BUILD_IMAGE
|
FROM node:22-alpine AS BUILD_IMAGE
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ RUN \
|
|||||||
;; \
|
;; \
|
||||||
esac
|
esac
|
||||||
|
|
||||||
RUN npm install --global pnpm
|
RUN npm install --global pnpm@9
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml postinstall-win.js ./
|
COPY package.json pnpm-lock.yaml postinstall-win.js ./
|
||||||
RUN CYPRESS_INSTALL_BINARY=0 pnpm install --frozen-lockfile
|
RUN CYPRESS_INSTALL_BINARY=0 pnpm install --frozen-lockfile
|
||||||
@@ -29,23 +29,32 @@ RUN pnpm build
|
|||||||
# remove development dependencies
|
# remove development dependencies
|
||||||
RUN pnpm prune --prod --ignore-scripts
|
RUN pnpm prune --prod --ignore-scripts
|
||||||
|
|
||||||
RUN rm -rf src server .next/cache
|
RUN rm -rf src server .next/cache charts gen-docs docs
|
||||||
|
|
||||||
RUN touch config/DOCKER
|
RUN touch config/DOCKER
|
||||||
|
|
||||||
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
||||||
|
|
||||||
|
|
||||||
FROM node:20-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
# Metadata for Github Package Registry
|
# OCI Meta information
|
||||||
LABEL org.opencontainers.image.source="https://github.com/Fallenbagel/jellyseerr"
|
ARG BUILD_DATE
|
||||||
|
ARG BUILD_VERSION
|
||||||
|
LABEL \
|
||||||
|
org.opencontainers.image.authors="Fallenbagel" \
|
||||||
|
org.opencontainers.image.source="https://github.com/fallenbagel/jellyseerr" \
|
||||||
|
org.opencontainers.image.created=${BUILD_DATE} \
|
||||||
|
org.opencontainers.image.version=${BUILD_VERSION} \
|
||||||
|
org.opencontainers.image.title="Jellyseerr" \
|
||||||
|
org.opencontainers.image.description="Open-source media request and discovery manager for Jellyfin, Plex, and Emby." \
|
||||||
|
org.opencontainers.image.licenses="MIT"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache tzdata tini && rm -rf /tmp/*
|
RUN apk add --no-cache tzdata tini && rm -rf /tmp/*
|
||||||
|
|
||||||
RUN npm install -g pnpm
|
RUN npm install -g pnpm@9
|
||||||
|
|
||||||
# copy from build image
|
# copy from build image
|
||||||
COPY --from=BUILD_IMAGE /app ./
|
COPY --from=BUILD_IMAGE /app ./
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
FROM node:20-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
COPY . /app
|
COPY . /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
Run npm install --global pnpm
|
RUN npm install --global pnpm@9
|
||||||
|
|
||||||
RUN pnpm install
|
RUN pnpm install
|
||||||
|
|
||||||
|
|||||||
131
README.md
@@ -11,17 +11,17 @@
|
|||||||
<a href="http://translate.jellyseerr.dev/engage/jellyseerr/"><img src="http://translate.jellyseerr.dev/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
<a href="http://translate.jellyseerr.dev/engage/jellyseerr/"><img src="http://translate.jellyseerr.dev/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
||||||
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
||||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-60-orange.svg"/></a>
|
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-69-orange.svg"/></a>
|
||||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||||
|
|
||||||
**Jellyseerr** is a free and open source software application for managing requests for your media library.
|
**Jellyseerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**.
|
||||||
It is a fork of [Overseerr](https://github.com/sct/overseerr) built to bring additional support for [Jellyfin](https://github.com/jellyfin/jellyfin) & [Emby](https://github.com/MediaBrowser/Emby) media servers!
|
|
||||||
|
|
||||||
## Current Features
|
## Current Features
|
||||||
|
|
||||||
- Full Jellyfin/Emby/Plex integration including authentication with user import & management
|
- Full Jellyfin/Emby/Plex integration including authentication with user import & management.
|
||||||
- Supports Movies, Shows and Mixed Libraries
|
- Support for **PostgreSQL** and **SQLite** databases.
|
||||||
- Ability to change email addresses for smtp purposes
|
- Supports Movies, Shows and Mixed Libraries.
|
||||||
|
- Ability to change email addresses for SMTP purposes.
|
||||||
- Easy integration with your existing services. Currently, Jellyseerr supports Sonarr and Radarr. More to come!
|
- Easy integration with your existing services. Currently, Jellyseerr supports Sonarr and Radarr. More to come!
|
||||||
- Jellyfin/Emby/Plex library scan, to keep track of the titles which are already available.
|
- Jellyfin/Emby/Plex library scan, 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.
|
- Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface.
|
||||||
@@ -29,8 +29,7 @@ It is a fork of [Overseerr](https://github.com/sct/overseerr) built to bring add
|
|||||||
- Granular permission system.
|
- Granular permission system.
|
||||||
- Support for various notification agents.
|
- Support for various notification agents.
|
||||||
- Mobile-friendly design, for when you need to approve requests on the go!
|
- Mobile-friendly design, for when you need to approve requests on the go!
|
||||||
|
- Support for watchlisting & blacklisting media.
|
||||||
(Upcoming Features include: Multiple Server Instances, and much more!)
|
|
||||||
|
|
||||||
With more features on the way! Check out our [issue tracker](https://github.com/fallenbagel/jellyseerr/issues) to see the features which have already been requested.
|
With more features on the way! Check out our [issue tracker](https://github.com/fallenbagel/jellyseerr/issues) to see the features which have already been requested.
|
||||||
|
|
||||||
@@ -87,82 +86,93 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fallenbagel" title="Code">💻</a> <a href="#maintenance-Fallenbagel" title="Maintenance">🚧</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Fallenbagel" title="Code">💻</a> <a href="#maintenance-Fallenbagel" title="Maintenance">🚧</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/seanzhang98"><img src="https://avatars.githubusercontent.com/u/34902361?v=4?s=100" width="100px;" alt="Sean"/><br /><sub><b>Sean</b></sub></a><br /><a href="#translation-seanzhang98" title="Translation">🌍</a> <a href="https://github.com/Fallenbagel/jellyseerr/commits?author=seanzhang98" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/seanzhang98"><img src="https://avatars.githubusercontent.com/u/34902361?v=4?s=100" width="100px;" alt="Sean"/><br /><sub><b>Sean</b></sub></a><br /><a href="#translation-seanzhang98" title="Translation">🌍</a> <a href="https://github.com/fallenbagel/jellyseerr/commits?author=seanzhang98" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/notfakie"><img src="https://avatars.githubusercontent.com/u/103784113?v=4?s=100" width="100px;" alt="notfakie"/><br /><sub><b>notfakie</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=notfakie" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/notfakie"><img src="https://avatars.githubusercontent.com/u/103784113?v=4?s=100" width="100px;" alt="notfakie"/><br /><sub><b>notfakie</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=notfakie" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Jumail"><img src="https://avatars.githubusercontent.com/u/7672055?v=4?s=100" width="100px;" alt="Mohamed Jumail"/><br /><sub><b>Mohamed Jumail</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/pulls?q=is%3Apr+reviewed-by%3AJumail" title="Reviewed Pull Requests">👀</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Jumail"><img src="https://avatars.githubusercontent.com/u/7672055?v=4?s=100" width="100px;" alt="Mohamed Jumail"/><br /><sub><b>Mohamed Jumail</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/pulls?q=is%3Apr+reviewed-by%3AJumail" title="Reviewed Pull Requests">👀</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://www.heywhale.com"><img src="https://avatars.githubusercontent.com/u/4048787?v=4?s=100" width="100px;" alt="Shilong Jiang"/><br /><sub><b>Shilong Jiang</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=jsl9208" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://www.heywhale.com"><img src="https://avatars.githubusercontent.com/u/4048787?v=4?s=100" width="100px;" alt="Shilong Jiang"/><br /><sub><b>Shilong Jiang</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jsl9208" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://jinas.me"><img src="https://avatars.githubusercontent.com/u/28459081?v=4?s=100" width="100px;" alt="Boring Dragon"/><br /><sub><b>Boring Dragon</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=boring-dragon" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://jinas.me"><img src="https://avatars.githubusercontent.com/u/28459081?v=4?s=100" width="100px;" alt="Boring Dragon"/><br /><sub><b>Boring Dragon</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=boring-dragon" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sambartik"><img src="https://avatars.githubusercontent.com/u/63553146?v=4?s=100" width="100px;" alt="Samuel Bartík"/><br /><sub><b>Samuel Bartík</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=sambartik" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sambartik"><img src="https://avatars.githubusercontent.com/u/63553146?v=4?s=100" width="100px;" alt="Samuel Bartík"/><br /><sub><b>Samuel Bartík</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=sambartik" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CyferShepard"><img src="https://avatars.githubusercontent.com/u/24864904?v=4?s=100" width="100px;" alt="Thegan Govender"/><br /><sub><b>Thegan Govender</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=CyferShepard" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CyferShepard"><img src="https://avatars.githubusercontent.com/u/24864904?v=4?s=100" width="100px;" alt="Thegan Govender"/><br /><sub><b>Thegan Govender</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=CyferShepard" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jab416171"><img src="https://avatars.githubusercontent.com/u/345752?v=4?s=100" width="100px;" alt="jab416171"/><br /><sub><b>jab416171</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=jab416171" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jab416171"><img src="https://avatars.githubusercontent.com/u/345752?v=4?s=100" width="100px;" alt="jab416171"/><br /><sub><b>jab416171</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jab416171" title="Documentation">📖</a> <a href="https://github.com/fallenbagel/jellyseerr/commits?author=jab416171" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://nvds.be"><img src="https://avatars.githubusercontent.com/u/5257222?v=4?s=100" width="100px;" alt="Nicolai Van der Storm"/><br /><sub><b>Nicolai Van der Storm</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=NicolaiVdS" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://nvds.be"><img src="https://avatars.githubusercontent.com/u/5257222?v=4?s=100" width="100px;" alt="Nicolai Van der Storm"/><br /><sub><b>Nicolai Van der Storm</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=NicolaiVdS" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Smexhy"><img src="https://avatars.githubusercontent.com/u/4880625?v=4?s=100" width="100px;" alt="Smexhy"/><br /><sub><b>Smexhy</b></sub></a><br /><a href="#translation-Smexhy" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Smexhy"><img src="https://avatars.githubusercontent.com/u/4880625?v=4?s=100" width="100px;" alt="Smexhy"/><br /><sub><b>Smexhy</b></sub></a><br /><a href="#translation-Smexhy" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://dd06-dev.fr"><img src="https://avatars.githubusercontent.com/u/58089504?v=4?s=100" width="100px;" alt="dd060606"/><br /><sub><b>dd060606</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=dd060606" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://dd06-dev.fr"><img src="https://avatars.githubusercontent.com/u/58089504?v=4?s=100" width="100px;" alt="dd060606"/><br /><sub><b>dd060606</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=dd060606" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://qwer.tz"><img src="https://avatars.githubusercontent.com/u/71837281?v=4?s=100" width="100px;" alt="Daniel"/><br /><sub><b>Daniel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=darmiel" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://qwer.tz"><img src="https://avatars.githubusercontent.com/u/71837281?v=4?s=100" width="100px;" alt="Daniel"/><br /><sub><b>Daniel</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=darmiel" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/undone37"><img src="https://avatars.githubusercontent.com/u/10513808?v=4?s=100" width="100px;" alt="undone37"/><br /><sub><b>undone37</b></sub></a><br /><a href="#translation-undone37" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/undone37"><img src="https://avatars.githubusercontent.com/u/10513808?v=4?s=100" width="100px;" alt="undone37"/><br /><sub><b>undone37</b></sub></a><br /><a href="#translation-undone37" title="Translation">🌍</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CheChu10"><img src="https://avatars.githubusercontent.com/u/32913133?v=4?s=100" width="100px;" alt="Chechu García"/><br /><sub><b>Chechu García</b></sub></a><br /><a href="#translation-CheChu10" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CheChu10"><img src="https://avatars.githubusercontent.com/u/32913133?v=4?s=100" width="100px;" alt="Chechu García"/><br /><sub><b>Chechu García</b></sub></a><br /><a href="#translation-CheChu10" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/DimitriDR"><img src="https://avatars.githubusercontent.com/u/56969769?v=4?s=100" width="100px;" alt="Dimitri"/><br /><sub><b>Dimitri</b></sub></a><br /><a href="#translation-DimitriDR" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/DimitriDR"><img src="https://avatars.githubusercontent.com/u/56969769?v=4?s=100" width="100px;" alt="Dimitri"/><br /><sub><b>Dimitri</b></sub></a><br /><a href="#translation-DimitriDR" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrey4korop"><img src="https://avatars.githubusercontent.com/u/24610708?v=4?s=100" width="100px;" alt="andrey4korop"/><br /><sub><b>andrey4korop</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=andrey4korop" title="Code">💻</a> <a href="#translation-andrey4korop" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrey4korop"><img src="https://avatars.githubusercontent.com/u/24610708?v=4?s=100" width="100px;" alt="andrey4korop"/><br /><sub><b>andrey4korop</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=andrey4korop" title="Code">💻</a> <a href="#translation-andrey4korop" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://geoffrey-coulaud.fr"><img src="https://avatars.githubusercontent.com/u/20744730?v=4?s=100" width="100px;" alt="Geoffrey Coulaud"/><br /><sub><b>Geoffrey Coulaud</b></sub></a><br /><a href="#translation-GeoffreyCoulaud" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://geoffrey-coulaud.fr"><img src="https://avatars.githubusercontent.com/u/20744730?v=4?s=100" width="100px;" alt="Geoffrey Coulaud"/><br /><sub><b>Geoffrey Coulaud</b></sub></a><br /><a href="#translation-GeoffreyCoulaud" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Pikachu920"><img src="https://avatars.githubusercontent.com/u/28607612?v=4?s=100" width="100px;" alt="Pikachu920"/><br /><sub><b>Pikachu920</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Pikachu920" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Pikachu920"><img src="https://avatars.githubusercontent.com/u/28607612?v=4?s=100" width="100px;" alt="Pikachu920"/><br /><sub><b>Pikachu920</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Pikachu920" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/yalagin"><img src="https://avatars.githubusercontent.com/u/12879142?v=4?s=100" width="100px;" alt="Maxim Yalagin"/><br /><sub><b>Maxim Yalagin</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=yalagin" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/yalagin"><img src="https://avatars.githubusercontent.com/u/12879142?v=4?s=100" width="100px;" alt="Maxim Yalagin"/><br /><sub><b>Maxim Yalagin</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=yalagin" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jeaboswell"><img src="https://avatars.githubusercontent.com/u/11653068?v=4?s=100" width="100px;" alt="Jesse Boswell"/><br /><sub><b>Jesse Boswell</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=jeaboswell" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jeaboswell"><img src="https://avatars.githubusercontent.com/u/11653068?v=4?s=100" width="100px;" alt="Jesse Boswell"/><br /><sub><b>Jesse Boswell</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jeaboswell" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/d-fendrich"><img src="https://avatars.githubusercontent.com/u/27904138?v=4?s=100" width="100px;" alt="d-fendrich"/><br /><sub><b>d-fendrich</b></sub></a><br /><a href="#translation-d-fendrich" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/d-fendrich"><img src="https://avatars.githubusercontent.com/u/27904138?v=4?s=100" width="100px;" alt="d-fendrich"/><br /><sub><b>d-fendrich</b></sub></a><br /><a href="#translation-d-fendrich" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/davidfdezalcoba"><img src="https://avatars.githubusercontent.com/u/15996018?v=4?s=100" width="100px;" alt="David Fernández Alcoba"/><br /><sub><b>David Fernández Alcoba</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=davidfdezalcoba" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/davidfdezalcoba"><img src="https://avatars.githubusercontent.com/u/15996018?v=4?s=100" width="100px;" alt="David Fernández Alcoba"/><br /><sub><b>David Fernández Alcoba</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=davidfdezalcoba" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Gauvino"><img src="https://avatars.githubusercontent.com/u/68083474?v=4?s=100" width="100px;" alt="Gauvino"/><br /><sub><b>Gauvino</b></sub></a><br /><a href="#translation-Gauvino" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Gauvino"><img src="https://avatars.githubusercontent.com/u/68083474?v=4?s=100" width="100px;" alt="Gauvino"/><br /><sub><b>Gauvino</b></sub></a><br /><a href="#translation-Gauvino" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/EthanArmbrust"><img src="https://avatars.githubusercontent.com/u/22754714?v=4?s=100" width="100px;" alt="EthanArmbrust"/><br /><sub><b>EthanArmbrust</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=EthanArmbrust" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/EthanArmbrust"><img src="https://avatars.githubusercontent.com/u/22754714?v=4?s=100" width="100px;" alt="EthanArmbrust"/><br /><sub><b>EthanArmbrust</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=EthanArmbrust" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://www.piribisoft.com"><img src="https://avatars.githubusercontent.com/u/854646?v=4?s=100" width="100px;" alt="Eduardo"/><br /><sub><b>Eduardo</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=SirMartin" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://www.piribisoft.com"><img src="https://avatars.githubusercontent.com/u/854646?v=4?s=100" width="100px;" alt="Eduardo"/><br /><sub><b>Eduardo</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=SirMartin" title="Documentation">📖</a> <a href="https://github.com/fallenbagel/jellyseerr/commits?author=SirMartin" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RickLuiken"><img src="https://avatars.githubusercontent.com/u/34110371?v=4?s=100" width="100px;" alt="RickLuiken"/><br /><sub><b>RickLuiken</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=RickLuiken" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RickLuiken"><img src="https://avatars.githubusercontent.com/u/34110371?v=4?s=100" width="100px;" alt="RickLuiken"/><br /><sub><b>RickLuiken</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=RickLuiken" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Br33ce"><img src="https://avatars.githubusercontent.com/u/124933490?v=4?s=100" width="100px;" alt="Br33ce"/><br /><sub><b>Br33ce</b></sub></a><br /><a href="#translation-Br33ce" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Br33ce"><img src="https://avatars.githubusercontent.com/u/124933490?v=4?s=100" width="100px;" alt="Br33ce"/><br /><sub><b>Br33ce</b></sub></a><br /><a href="#translation-Br33ce" title="Translation">🌍</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://athfan.com"><img src="https://avatars.githubusercontent.com/u/13810742?v=4?s=100" width="100px;" alt="Athfan Khaleel"/><br /><sub><b>Athfan Khaleel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=athphane" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://athfan.com"><img src="https://avatars.githubusercontent.com/u/13810742?v=4?s=100" width="100px;" alt="Athfan Khaleel"/><br /><sub><b>Athfan Khaleel</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=athphane" title="Documentation">📖</a> <a href="https://github.com/fallenbagel/jellyseerr/commits?author=athphane" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mdll23"><img src="https://avatars.githubusercontent.com/u/142844478?v=4?s=100" width="100px;" alt="Michael Dallinger"/><br /><sub><b>Michael Dallinger</b></sub></a><br /><a href="#translation-mdll23" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mdll23"><img src="https://avatars.githubusercontent.com/u/142844478?v=4?s=100" width="100px;" alt="Michael Dallinger"/><br /><sub><b>Michael Dallinger</b></sub></a><br /><a href="#translation-mdll23" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xeruf"><img src="https://avatars.githubusercontent.com/u/13354331?v=4?s=100" width="100px;" alt="Janek"/><br /><sub><b>Janek</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=xeruf" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xeruf"><img src="https://avatars.githubusercontent.com/u/13354331?v=4?s=100" width="100px;" alt="Janek"/><br /><sub><b>Janek</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=xeruf" title="Documentation">📖</a> <a href="https://github.com/fallenbagel/jellyseerr/commits?author=xeruf" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://aleksasiriski.dev"><img src="https://avatars.githubusercontent.com/u/31509435?v=4?s=100" width="100px;" alt="Aleksa Siriški"/><br /><sub><b>Aleksa Siriški</b></sub></a><br /><a href="#infra-aleksasiriski" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://aleksasiriski.dev"><img src="https://avatars.githubusercontent.com/u/31509435?v=4?s=100" width="100px;" alt="Aleksa Siriški"/><br /><sub><b>Aleksa Siriški</b></sub></a><br /><a href="#infra-aleksasiriski" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://danishhumair.com"><img src="https://avatars.githubusercontent.com/u/121830048?v=4?s=100" width="100px;" alt="Danish Humair"/><br /><sub><b>Danish Humair</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Danish-H" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://danishhumair.com"><img src="https://avatars.githubusercontent.com/u/121830048?v=4?s=100" width="100px;" alt="Danish Humair"/><br /><sub><b>Danish Humair</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Danish-H" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://arm0.red"><img src="https://avatars.githubusercontent.com/u/16858514?v=4?s=100" width="100px;" alt="Stephen Harris"/><br /><sub><b>Stephen Harris</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=trackmastersteve" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://arm0.red"><img src="https://avatars.githubusercontent.com/u/16858514?v=4?s=100" width="100px;" alt="Stephen Harris"/><br /><sub><b>Stephen Harris</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=trackmastersteve" title="Documentation">📖</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://www.boniface.me"><img src="https://avatars.githubusercontent.com/u/4031396?v=4?s=100" width="100px;" alt="Joshua M. Boniface"/><br /><sub><b>Joshua M. Boniface</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=joshuaboniface" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://www.boniface.me"><img src="https://avatars.githubusercontent.com/u/4031396?v=4?s=100" width="100px;" alt="Joshua M. Boniface"/><br /><sub><b>Joshua M. Boniface</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=joshuaboniface" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=gauthier-th" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=gauthier-th" title="Code">💻</a> <a href="#maintenance-gauthier-th" title="Maintenance">🚧</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Kara-Zor-El"><img src="https://avatars.githubusercontent.com/u/69772087?v=4?s=100" width="100px;" alt="Kara"/><br /><sub><b>Kara</b></sub></a><br /><a href="#infra-Kara-Zor-El" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Kara-Zor-El"><img src="https://avatars.githubusercontent.com/u/69772087?v=4?s=100" width="100px;" alt="Kara"/><br /><sub><b>Kara</b></sub></a><br /><a href="#infra-Kara-Zor-El" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://mobihen.com"><img src="https://avatars.githubusercontent.com/u/35529491?v=4?s=100" width="100px;" alt="Nir Israel Hen"/><br /><sub><b>Nir Israel Hen</b></sub></a><br /><a href="#translation-mobihen" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://mobihen.com"><img src="https://avatars.githubusercontent.com/u/35529491?v=4?s=100" width="100px;" alt="Nir Israel Hen"/><br /><sub><b>Nir Israel Hen</b></sub></a><br /><a href="#translation-mobihen" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/XDark187"><img src="https://avatars.githubusercontent.com/u/39034192?v=4?s=100" width="100px;" alt="Baraa"/><br /><sub><b>Baraa</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=XDark187" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/XDark187"><img src="https://avatars.githubusercontent.com/u/39034192?v=4?s=100" width="100px;" alt="Baraa"/><br /><sub><b>Baraa</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=XDark187" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/franciscofsales"><img src="https://avatars.githubusercontent.com/u/7977645?v=4?s=100" width="100px;" alt="Francisco Sales"/><br /><sub><b>Francisco Sales</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=franciscofsales" title="Code">💻</a></td>
|
</tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/myselfolli"><img src="https://avatars.githubusercontent.com/u/37535998?v=4?s=100" width="100px;" alt="Oliver Laing"/><br /><sub><b>Oliver Laing</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=myselfolli" title="Code">💻</a></td>
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/franciscofsales"><img src="https://avatars.githubusercontent.com/u/7977645?v=4?s=100" width="100px;" alt="Francisco Sales"/><br /><sub><b>Francisco Sales</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=franciscofsales" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/myselfolli"><img src="https://avatars.githubusercontent.com/u/37535998?v=4?s=100" width="100px;" alt="Oliver Laing"/><br /><sub><b>Oliver Laing</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=myselfolli" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/M0NsTeRRR"><img src="https://avatars.githubusercontent.com/u/37785089?v=4?s=100" width="100px;" alt="Ludovic Ortega"/><br /><sub><b>Ludovic Ortega</b></sub></a><br /><a href="#security-M0NsTeRRR" title="Security">🛡️</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/M0NsTeRRR"><img src="https://avatars.githubusercontent.com/u/37785089?v=4?s=100" width="100px;" alt="Ludovic Ortega"/><br /><sub><b>Ludovic Ortega</b></sub></a><br /><a href="#security-M0NsTeRRR" title="Security">🛡️</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zariel"><img src="https://avatars.githubusercontent.com/u/2213?v=4?s=100" width="100px;" alt="Chris Bannister"/><br /><sub><b>Chris Bannister</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Zariel" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/C4J3"><img src="https://avatars.githubusercontent.com/u/13005453?v=4?s=100" width="100px;" alt="Joe"/><br /><sub><b>Joe</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=C4J3" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://me.garnx.fr"><img src="https://avatars.githubusercontent.com/u/37373941?v=4?s=100" width="100px;" alt="Guillaume ARNOUX"/><br /><sub><b>Guillaume ARNOUX</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=guillaumearnx" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dr-carrot"><img src="https://avatars.githubusercontent.com/u/17272571?v=4?s=100" width="100px;" alt="dr-carrot"/><br /><sub><b>dr-carrot</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=dr-carrot" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/gageorsburn"><img src="https://avatars.githubusercontent.com/u/4692734?v=4?s=100" width="100px;" alt="Gage Orsburn"/><br /><sub><b>Gage Orsburn</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=gageorsburn" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/GkhnGRBZ"><img src="https://avatars.githubusercontent.com/u/127258824?v=4?s=100" width="100px;" alt="GkhnGRBZ"/><br /><sub><b>GkhnGRBZ</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=GkhnGRBZ" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/demrich"><img src="https://avatars.githubusercontent.com/u/30092389?v=4?s=100" width="100px;" alt="David Emrich"/><br /><sub><b>David Emrich</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=demrich" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://benhaney.com"><img src="https://avatars.githubusercontent.com/u/31331498?v=4?s=100" width="100px;" alt="Ben Haney"/><br /><sub><b>Ben Haney</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=benhaney" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://maxtrier.dk"><img src="https://avatars.githubusercontent.com/u/5898152?v=4?s=100" width="100px;" alt="Max T. Kristiansen"/><br /><sub><b>Max T. Kristiansen</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=maxnatamo" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Wunderharke"><img src="https://avatars.githubusercontent.com/u/5105672?v=4?s=100" width="100px;" alt="Wunderharke"/><br /><sub><b>Wunderharke</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Wunderharke" title="Documentation">📖</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://damsdev.me"><img src="https://avatars.githubusercontent.com/u/60252259?v=4?s=100" width="100px;" alt="Damien Fajole"/><br /><sub><b>Damien Fajole</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=DamsDev1" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/methbkts"><img src="https://avatars.githubusercontent.com/u/30674934?v=4?s=100" width="100px;" alt="Metin Bektas"/><br /><sub><b>Metin Bektas</b></sub></a><br /><a href="#infra-methbkts" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrewkolda"><img src="https://avatars.githubusercontent.com/u/158614532?v=4?s=100" width="100px;" alt="andrewkolda"/><br /><sub><b>andrewkolda</b></sub></a><br /><a href="#design-andrewkolda" title="Design">🎨</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zariel"><img src="https://avatars.githubusercontent.com/u/2213?v=4?s=100" width="100px;" alt="Chris Bannister"/><br /><sub><b>Chris Bannister</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Zariel" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://ishanjain.me"><img src="https://avatars.githubusercontent.com/u/7921368?v=4?s=100" width="100px;" alt="Ishan Jain"/><br /><sub><b>Ishan Jain</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=ishanjain28" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/C4J3"><img src="https://avatars.githubusercontent.com/u/13005453?v=4?s=100" width="100px;" alt="Joe"/><br /><sub><b>Joe</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=C4J3" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://michaelt.xyz"><img src="https://avatars.githubusercontent.com/u/18223295?v=4?s=100" width="100px;" alt="Michael Thomas"/><br /><sub><b>Michael Thomas</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=michaelhthomas" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://me.garnx.fr"><img src="https://avatars.githubusercontent.com/u/37373941?v=4?s=100" width="100px;" alt="Guillaume ARNOUX"/><br /><sub><b>Guillaume ARNOUX</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=guillaumearnx" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RankWeis"><img src="https://avatars.githubusercontent.com/u/733691?v=4?s=100" width="100px;" alt="RankWeis"/><br /><sub><b>RankWeis</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=RankWeis" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dr-carrot"><img src="https://avatars.githubusercontent.com/u/17272571?v=4?s=100" width="100px;" alt="dr-carrot"/><br /><sub><b>dr-carrot</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=dr-carrot" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://www.linkedin.com/in/jessielwilson"><img src="https://avatars.githubusercontent.com/u/48299282?v=4?s=100" width="100px;" alt="Jessie Wilson"/><br /><sub><b>Jessie Wilson</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jessielw" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/gageorsburn"><img src="https://avatars.githubusercontent.com/u/4692734?v=4?s=100" width="100px;" alt="Gage Orsburn"/><br /><sub><b>Gage Orsburn</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=gageorsburn" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/brotaxt"><img src="https://avatars.githubusercontent.com/u/25477935?v=4?s=100" width="100px;" alt="DominicKo"/><br /><sub><b>DominicKo</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=brotaxt" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://doctolib.com"><img src="https://avatars.githubusercontent.com/u/30508927?v=4?s=100" width="100px;" alt="Corentin Normand"/><br /><sub><b>Corentin Normand</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=corentinnormand" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/benbeauchamp7"><img src="https://avatars.githubusercontent.com/u/43358492?v=4?s=100" width="100px;" alt="Ben Beauchamp"/><br /><sub><b>Ben Beauchamp</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=benbeauchamp7" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/vfaergestad"><img src="https://avatars.githubusercontent.com/u/49147564?v=4?s=100" width="100px;" alt="vfaergestad"/><br /><sub><b>vfaergestad</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=vfaergestad" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/wolffman122"><img src="https://avatars.githubusercontent.com/u/19178872?v=4?s=100" width="100px;" alt="wolffman122"/><br /><sub><b>wolffman122</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=wolffman122" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Schrottfresser"><img src="https://avatars.githubusercontent.com/u/39998368?v=4?s=100" width="100px;" alt="Schrottfresser"/><br /><sub><b>Schrottfresser</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Schrottfresser" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/DillionLowry"><img src="https://avatars.githubusercontent.com/u/91228469?v=4?s=100" width="100px;" alt="Dillion"/><br /><sub><b>Dillion</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=DillionLowry" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JamsRepos"><img src="https://avatars.githubusercontent.com/u/1347620?v=4?s=100" width="100px;" alt="Jam"/><br /><sub><b>Jam</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=JamsRepos" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://www.joelowrance.com"><img src="https://avatars.githubusercontent.com/u/63176?v=4?s=100" width="100px;" alt="Joe Lowrance"/><br /><sub><b>Joe Lowrance</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=joelowrance" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/0xSysR3ll"><img src="https://avatars.githubusercontent.com/u/31414959?v=4?s=100" width="100px;" alt="0xsysr3ll"/><br /><sub><b>0xsysr3ll</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=0xSysR3ll" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -280,7 +290,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/byakurau"><img src="https://avatars.githubusercontent.com/u/1811683?v=4?s=100" width="100px;" alt="byakurau"/><br /><sub><b>byakurau</b></sub></a><br /><a href="#translation-byakurau" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/byakurau"><img src="https://avatars.githubusercontent.com/u/1811683?v=4?s=100" width="100px;" alt="byakurau"/><br /><sub><b>byakurau</b></sub></a><br /><a href="#translation-byakurau" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/miknii"><img src="https://avatars.githubusercontent.com/u/109232569?v=4?s=100" width="100px;" alt="miknii"/><br /><sub><b>miknii</b></sub></a><br /><a href="#translation-miknii" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/miknii"><img src="https://avatars.githubusercontent.com/u/109232569?v=4?s=100" width="100px;" alt="miknii"/><br /><sub><b>miknii</b></sub></a><br /><a href="#translation-miknii" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Eclipseop"><img src="https://avatars.githubusercontent.com/u/5846213?v=4?s=100" width="100px;" alt="Mackenzie"/><br /><sub><b>Mackenzie</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Eclipseop" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Eclipseop"><img src="https://avatars.githubusercontent.com/u/5846213?v=4?s=100" width="100px;" alt="Mackenzie"/><br /><sub><b>Mackenzie</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Eclipseop" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/s0up4200"><img src="https://avatars.githubusercontent.com/u/18177310?v=4?s=100" width="100px;" alt="soup"/><br /><sub><b>soup</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=s0up4200" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/s0up4200"><img src="https://avatars.githubusercontent.com/u/18177310?v=4?s=100" width="100px;" alt="soup"/><br /><sub><b>soup</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=s0up4200" title="Documentation">📖</a> <a href="https://github.com/sct/overseerr/commits?author=s0up4200" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ceptonit"><img src="https://avatars.githubusercontent.com/u/12678743?v=4?s=100" width="100px;" alt="ceptonit"/><br /><sub><b>ceptonit</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ceptonit" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ceptonit"><img src="https://avatars.githubusercontent.com/u/12678743?v=4?s=100" width="100px;" alt="ceptonit"/><br /><sub><b>ceptonit</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ceptonit" title="Documentation">📖</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/aedelbro"><img src="https://avatars.githubusercontent.com/u/36162221?v=4?s=100" width="100px;" alt="aedelbro"/><br /><sub><b>aedelbro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=aedelbro" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/aedelbro"><img src="https://avatars.githubusercontent.com/u/36162221?v=4?s=100" width="100px;" alt="aedelbro"/><br /><sub><b>aedelbro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=aedelbro" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -297,7 +307,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
<td align="center" valign="top" width="14.28%"><a href="https://izaacj.me"><img src="https://avatars.githubusercontent.com/u/711323?v=4?s=100" width="100px;" alt="Izaac Brånn"/><br /><sub><b>Izaac Brånn</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=IzaacJ" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://izaacj.me"><img src="https://avatars.githubusercontent.com/u/711323?v=4?s=100" width="100px;" alt="Izaac Brånn"/><br /><sub><b>Izaac Brånn</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=IzaacJ" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/SalmanTariq"><img src="https://avatars.githubusercontent.com/u/13284494?v=4?s=100" width="100px;" alt="Salman Tariq"/><br /><sub><b>Salman Tariq</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=SalmanTariq" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/SalmanTariq"><img src="https://avatars.githubusercontent.com/u/13284494?v=4?s=100" width="100px;" alt="Salman Tariq"/><br /><sub><b>Salman Tariq</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=SalmanTariq" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrew-kennedy"><img src="https://avatars.githubusercontent.com/u/2387159?v=4?s=100" width="100px;" alt="Andrew Kennedy"/><br /><sub><b>Andrew Kennedy</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=andrew-kennedy" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrew-kennedy"><img src="https://avatars.githubusercontent.com/u/2387159?v=4?s=100" width="100px;" alt="Andrew Kennedy"/><br /><sub><b>Andrew Kennedy</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=andrew-kennedy" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fallenbagel" title="Jellyseerr">🪼⌨️</a> <a href="https://github.com/sct/overseerr/commits?author=Fallenbagel" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Fallenbagel" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://aidoge.xyz"><img src="https://avatars.githubusercontent.com/u/9427639?v=4?s=100" width="100px;" alt="Anton K. (ai Doge)"/><br /><sub><b>Anton K. (ai Doge)</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=scorp200" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://aidoge.xyz"><img src="https://avatars.githubusercontent.com/u/9427639?v=4?s=100" width="100px;" alt="Anton K. (ai Doge)"/><br /><sub><b>Anton K. (ai Doge)</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=scorp200" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://marcofaggian.com"><img src="https://avatars.githubusercontent.com/u/19221001?v=4?s=100" width="100px;" alt="Marco Faggian"/><br /><sub><b>Marco Faggian</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=marcofaggian" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://marcofaggian.com"><img src="https://avatars.githubusercontent.com/u/19221001?v=4?s=100" width="100px;" alt="Marco Faggian"/><br /><sub><b>Marco Faggian</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=marcofaggian" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://nemchik.com/"><img src="https://avatars.githubusercontent.com/u/725456?v=4?s=100" width="100px;" alt="Eric Nemchik"/><br /><sub><b>Eric Nemchik</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=nemchik" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://nemchik.com/"><img src="https://avatars.githubusercontent.com/u/725456?v=4?s=100" width="100px;" alt="Eric Nemchik"/><br /><sub><b>Eric Nemchik</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=nemchik" title="Code">💻</a></td>
|
||||||
@@ -313,6 +323,11 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JackW6809"><img src="https://avatars.githubusercontent.com/u/53652452?v=4?s=100" width="100px;" alt="JackOXI"/><br /><sub><b>JackOXI</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JackW6809" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://indicus.ro"><img src="https://avatars.githubusercontent.com/u/1199404?v=4?s=100" width="100px;" alt="Stancu Florin"/><br /><sub><b>Stancu Florin</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=StancuFlorin" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lmiklosko"><img src="https://avatars.githubusercontent.com/u/44380311?v=4?s=100" width="100px;" alt="Lukas Miklosko"/><br /><sub><b>Lukas Miklosko</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=lmiklosko" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=gauthier-th" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/vfaergestad"><img src="https://avatars.githubusercontent.com/u/49147564?v=4?s=100" width="100px;" alt="vfaergestad"/><br /><sub><b>vfaergestad</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=vfaergestad" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -21,3 +21,5 @@
|
|||||||
.idea/
|
.idea/
|
||||||
*.tmproj
|
*.tmproj
|
||||||
.vscode/
|
.vscode/
|
||||||
|
# go template
|
||||||
|
*.gotmpl
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
kubeVersion: ">=1.23.0-0"
|
kubeVersion: ">=1.23.0-0"
|
||||||
name: Jellyseerr
|
name: jellyseerr-chart
|
||||||
description: Jellyseerr helm chart for Kubernetes
|
description: Jellyseerr helm chart for Kubernetes
|
||||||
type: application
|
type: application
|
||||||
version: 1.1.0
|
version: 2.6.2
|
||||||
appVersion: "2.1.0"
|
appVersion: "2.7.3"
|
||||||
maintainers:
|
maintainers:
|
||||||
- name: Jellyseerr
|
- name: Jellyseerr
|
||||||
url: https://github.com/Fallenbagel/jellyseerr
|
url: https://github.com/Fallenbagel/jellyseerr
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Jellyseerr
|
# jellyseerr-chart
|
||||||
|
|
||||||
  
|
  
|
||||||
|
|
||||||
Jellyseerr helm chart for Kubernetes
|
Jellyseerr helm chart for Kubernetes
|
||||||
|
|
||||||
@@ -25,10 +25,6 @@ Kubernetes: `>=1.23.0-0`
|
|||||||
| Key | Type | Default | Description |
|
| Key | Type | Default | Description |
|
||||||
|-----|------|---------|-------------|
|
|-----|------|---------|-------------|
|
||||||
| affinity | object | `{}` | |
|
| affinity | object | `{}` | |
|
||||||
| autoscaling.enabled | bool | `false` | |
|
|
||||||
| autoscaling.maxReplicas | int | `100` | |
|
|
||||||
| autoscaling.minReplicas | int | `1` | |
|
|
||||||
| autoscaling.targetCPUUtilizationPercentage | int | `80` | |
|
|
||||||
| config | object | `{"persistence":{"accessModes":["ReadWriteOnce"],"annotations":{},"name":"","size":"5Gi","volumeName":""}}` | Creating PVC to store configuration |
|
| config | object | `{"persistence":{"accessModes":["ReadWriteOnce"],"annotations":{},"name":"","size":"5Gi","volumeName":""}}` | Creating PVC to store configuration |
|
||||||
| config.persistence.accessModes | list | `["ReadWriteOnce"]` | Access modes of persistent disk |
|
| config.persistence.accessModes | list | `["ReadWriteOnce"]` | Access modes of persistent disk |
|
||||||
| config.persistence.annotations | object | `{}` | Annotations for PVCs |
|
| config.persistence.annotations | object | `{}` | Annotations for PVCs |
|
||||||
@@ -39,7 +35,7 @@ Kubernetes: `>=1.23.0-0`
|
|||||||
| extraEnvFrom | list | `[]` | Environment variables from secrets or configmaps to add to the jellyseerr pods |
|
| extraEnvFrom | list | `[]` | Environment variables from secrets or configmaps to add to the jellyseerr pods |
|
||||||
| fullnameOverride | string | `""` | |
|
| fullnameOverride | string | `""` | |
|
||||||
| image.pullPolicy | string | `"IfNotPresent"` | |
|
| image.pullPolicy | string | `"IfNotPresent"` | |
|
||||||
| image.registry | string | `"docker.io"` | |
|
| image.registry | string | `"ghcr.io"` | |
|
||||||
| image.repository | string | `"fallenbagel/jellyseerr"` | |
|
| image.repository | string | `"fallenbagel/jellyseerr"` | |
|
||||||
| image.sha | string | `""` | |
|
| image.sha | string | `""` | |
|
||||||
| image.tag | string | `""` | Overrides the image tag whose default is the chart appVersion. |
|
| image.tag | string | `""` | Overrides the image tag whose default is the chart appVersion. |
|
||||||
@@ -56,6 +52,9 @@ Kubernetes: `>=1.23.0-0`
|
|||||||
| podAnnotations | object | `{}` | |
|
| podAnnotations | object | `{}` | |
|
||||||
| podLabels | object | `{}` | |
|
| podLabels | object | `{}` | |
|
||||||
| podSecurityContext | object | `{}` | |
|
| podSecurityContext | object | `{}` | |
|
||||||
|
| probes.livenessProbe | object | `{}` | Configure liveness probe |
|
||||||
|
| probes.readinessProbe | object | `{}` | Configure readiness probe |
|
||||||
|
| probes.startupProbe | string | `nil` | Configure startup probe |
|
||||||
| replicaCount | int | `1` | |
|
| replicaCount | int | `1` | |
|
||||||
| resources | object | `{}` | |
|
| resources | object | `{}` | |
|
||||||
| securityContext | object | `{}` | |
|
| securityContext | object | `{}` | |
|
||||||
@@ -67,3 +66,5 @@ Kubernetes: `>=1.23.0-0`
|
|||||||
| serviceAccount.name | string | `""` | If not set and create is true, a name is generated using the fullname template |
|
| serviceAccount.name | string | `""` | If not set and create is true, a name is generated using the fullname template |
|
||||||
| strategy | object | `{"type":"Recreate"}` | Deployment strategy |
|
| strategy | object | `{"type":"Recreate"}` | Deployment strategy |
|
||||||
| tolerations | list | `[]` | |
|
| tolerations | list | `[]` | |
|
||||||
|
| volumeMounts | list | `[]` | Additional volumeMounts on the output Deployment definition. |
|
||||||
|
| volumes | list | `[]` | Additional volumes on the output Deployment definition. |
|
||||||
1
charts/jellyseerr-chart/artifacthub-repo.yml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
repositoryID: c6b3f2dc-444c-4e37-b397-6a5ff563ee8b
|
||||||
@@ -5,9 +5,7 @@ metadata:
|
|||||||
labels:
|
labels:
|
||||||
{{- include "jellyseerr.labels" . | nindent 4 }}
|
{{- include "jellyseerr.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
{{- if not .Values.autoscaling.enabled }}
|
|
||||||
replicas: {{ .Values.replicaCount }}
|
replicas: {{ .Values.replicaCount }}
|
||||||
{{- end }}
|
|
||||||
strategy:
|
strategy:
|
||||||
type: {{ .Values.strategy.type }}
|
type: {{ .Values.strategy.type }}
|
||||||
selector:
|
selector:
|
||||||
@@ -50,10 +48,44 @@ spec:
|
|||||||
httpGet:
|
httpGet:
|
||||||
path: /
|
path: /
|
||||||
port: http
|
port: http
|
||||||
|
{{- if .Values.probes.livenessProbe.initialDelaySeconds }}
|
||||||
|
initialDelaySeconds: {{ .Values.probes.livenessProbe.initialDelaySeconds }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.probes.livenessProbe.periodSeconds }}
|
||||||
|
periodSeconds: {{ .Values.probes.livenessProbe.periodSeconds }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.probes.livenessProbe.timeoutSeconds }}
|
||||||
|
timeoutSeconds: {{ .Values.probes.livenessProbe.timeoutSeconds }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.probes.livenessProbe.successThreshold }}
|
||||||
|
successThreshold: {{ .Values.probes.livenessProbe.successThreshold }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.probes.livenessProbe.failureThreshold }}
|
||||||
|
failureThreshold: {{ .Values.probes.livenessProbe.failureThreshold }}
|
||||||
|
{{- end }}
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /
|
path: /
|
||||||
port: http
|
port: http
|
||||||
|
{{- if .Values.probes.readinessProbe.initialDelaySeconds }}
|
||||||
|
initialDelaySeconds: {{ .Values.probes.readinessProbe.initialDelaySeconds }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.probes.readinessProbe.periodSeconds }}
|
||||||
|
periodSeconds: {{ .Values.probes.readinessProbe.periodSeconds }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.probes.readinessProbe.timeoutSeconds }}
|
||||||
|
timeoutSeconds: {{ .Values.probes.readinessProbe.timeoutSeconds }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.probes.readinessProbe.successThreshold }}
|
||||||
|
successThreshold: {{ .Values.probes.readinessProbe.successThreshold }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.probes.readinessProbe.failureThreshold }}
|
||||||
|
failureThreshold: {{ .Values.probes.readinessProbe.failureThreshold }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.probes.startupProbe }}
|
||||||
|
startupProbe:
|
||||||
|
{{- toYaml .Values.probes.startupProbe | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
resources:
|
resources:
|
||||||
{{- toYaml .Values.resources | nindent 12 }}
|
{{- toYaml .Values.resources | nindent 12 }}
|
||||||
{{- with .Values.extraEnv }}
|
{{- with .Values.extraEnv }}
|
||||||
@@ -67,10 +99,16 @@ spec:
|
|||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: config
|
- name: config
|
||||||
mountPath: /app/config
|
mountPath: /app/config
|
||||||
|
{{- with .Values.volumeMounts }}
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
volumes:
|
volumes:
|
||||||
- name: config
|
- name: config
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: {{ include "jellyseerr.configPersistenceName" . }}
|
claimName: {{ include "jellyseerr.configPersistenceName" . }}
|
||||||
|
{{- with .Values.volumes }}
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
{{- with .Values.nodeSelector }}
|
{{- with .Values.nodeSelector }}
|
||||||
nodeSelector:
|
nodeSelector:
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
@@ -4,6 +4,10 @@ metadata:
|
|||||||
name: {{ include "jellyseerr.configPersistenceName" . }}
|
name: {{ include "jellyseerr.configPersistenceName" . }}
|
||||||
labels:
|
labels:
|
||||||
{{- include "jellyseerr.labels" . | nindent 4 }}
|
{{- include "jellyseerr.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.config.persistence.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
spec:
|
spec:
|
||||||
{{- with .Values.config.persistence.accessModes }}
|
{{- with .Values.config.persistence.accessModes }}
|
||||||
accessModes:
|
accessModes:
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
replicaCount: 1
|
replicaCount: 1
|
||||||
|
|
||||||
image:
|
image:
|
||||||
registry: docker.io
|
registry: ghcr.io
|
||||||
repository: fallenbagel/jellyseerr
|
repository: fallenbagel/jellyseerr
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
# -- Overrides the image tag whose default is the chart appVersion.
|
# -- Overrides the image tag whose default is the chart appVersion.
|
||||||
@@ -16,6 +16,27 @@ fullnameOverride: ""
|
|||||||
strategy:
|
strategy:
|
||||||
type: Recreate
|
type: Recreate
|
||||||
|
|
||||||
|
# Liveness / Readiness / Startup Probes
|
||||||
|
probes:
|
||||||
|
# -- Configure liveness probe
|
||||||
|
livenessProbe: {}
|
||||||
|
# initialDelaySeconds: 60
|
||||||
|
# periodSeconds: 30
|
||||||
|
# timeoutSeconds: 5
|
||||||
|
# successThreshold: 1
|
||||||
|
# failureThreshold: 5
|
||||||
|
# -- Configure readiness probe
|
||||||
|
readinessProbe: {}
|
||||||
|
# initialDelaySeconds: 60
|
||||||
|
# periodSeconds: 30
|
||||||
|
# timeoutSeconds: 5
|
||||||
|
# successThreshold: 1
|
||||||
|
# failureThreshold: 5
|
||||||
|
# -- Configure startup probe
|
||||||
|
startupProbe: null
|
||||||
|
# tcpSocket:
|
||||||
|
# port: http
|
||||||
|
|
||||||
# -- Environment variables to add to the jellyseerr pods
|
# -- Environment variables to add to the jellyseerr pods
|
||||||
extraEnv: []
|
extraEnv: []
|
||||||
# -- Environment variables from secrets or configmaps to add to the jellyseerr pods
|
# -- Environment variables from secrets or configmaps to add to the jellyseerr pods
|
||||||
@@ -36,15 +57,15 @@ podAnnotations: {}
|
|||||||
podLabels: {}
|
podLabels: {}
|
||||||
|
|
||||||
podSecurityContext: {}
|
podSecurityContext: {}
|
||||||
# fsGroup: 2000
|
# fsGroup: 2000
|
||||||
|
|
||||||
securityContext: {}
|
securityContext: {}
|
||||||
# capabilities:
|
# capabilities:
|
||||||
# drop:
|
# drop:
|
||||||
# - ALL
|
# - ALL
|
||||||
# readOnlyRootFilesystem: true
|
# readOnlyRootFilesystem: true
|
||||||
# runAsNonRoot: true
|
# runAsNonRoot: true
|
||||||
# runAsUser: 1000
|
# runAsUser: 1000
|
||||||
|
|
||||||
service:
|
service:
|
||||||
type: ClusterIP
|
type: ClusterIP
|
||||||
@@ -70,8 +91,8 @@ ingress:
|
|||||||
enabled: false
|
enabled: false
|
||||||
ingressClassName: ""
|
ingressClassName: ""
|
||||||
annotations: {}
|
annotations: {}
|
||||||
# kubernetes.io/ingress.class: nginx
|
# kubernetes.io/ingress.class: nginx
|
||||||
# kubernetes.io/tls-acme: "true"
|
# kubernetes.io/tls-acme: "true"
|
||||||
hosts:
|
hosts:
|
||||||
- host: chart-example.local
|
- host: chart-example.local
|
||||||
paths:
|
paths:
|
||||||
@@ -83,23 +104,29 @@ ingress:
|
|||||||
# - chart-example.local
|
# - chart-example.local
|
||||||
|
|
||||||
resources: {}
|
resources: {}
|
||||||
# We usually recommend not to specify default resources and to leave this as a conscious
|
# We usually recommend not to specify default resources and to leave this as a conscious
|
||||||
# choice for the user. This also increases chances charts run on environments with little
|
# choice for the user. This also increases chances charts run on environments with little
|
||||||
# resources, such as Minikube. If you do want to specify resources, uncomment the following
|
# resources, such as Minikube. If you do want to specify resources, uncomment the following
|
||||||
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
|
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
|
||||||
# limits:
|
# limits:
|
||||||
# cpu: 100m
|
# cpu: 100m
|
||||||
# memory: 128Mi
|
# memory: 128Mi
|
||||||
# requests:
|
# requests:
|
||||||
# cpu: 100m
|
# cpu: 100m
|
||||||
# memory: 128Mi
|
# memory: 128Mi
|
||||||
|
|
||||||
autoscaling:
|
# -- Additional volumes on the output Deployment definition.
|
||||||
enabled: false
|
volumes: []
|
||||||
minReplicas: 1
|
# - name: foo
|
||||||
maxReplicas: 100
|
# secret:
|
||||||
targetCPUUtilizationPercentage: 80
|
# secretName: mysecret
|
||||||
# targetMemoryUtilizationPercentage: 80
|
# optional: false
|
||||||
|
|
||||||
|
# -- Additional volumeMounts on the output Deployment definition.
|
||||||
|
volumeMounts: []
|
||||||
|
# - name: foo
|
||||||
|
# mountPath: "/etc/foo"
|
||||||
|
# readOnly: true
|
||||||
|
|
||||||
nodeSelector: {}
|
nodeSelector: {}
|
||||||
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{{- if .Values.autoscaling.enabled }}
|
|
||||||
apiVersion: autoscaling/v2
|
|
||||||
kind: HorizontalPodAutoscaler
|
|
||||||
metadata:
|
|
||||||
name: {{ include "jellyseerr.fullname" . }}
|
|
||||||
labels:
|
|
||||||
{{- include "jellyseerr.labels" . | nindent 4 }}
|
|
||||||
spec:
|
|
||||||
scaleTargetRef:
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
name: {{ include "jellyseerr.fullname" . }}
|
|
||||||
minReplicas: {{ .Values.autoscaling.minReplicas }}
|
|
||||||
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
|
|
||||||
metrics:
|
|
||||||
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
|
|
||||||
- type: Resource
|
|
||||||
resource:
|
|
||||||
name: cpu
|
|
||||||
target:
|
|
||||||
type: Utilization
|
|
||||||
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
|
||||||
{{- end }}
|
|
||||||
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
|
||||||
- type: Resource
|
|
||||||
resource:
|
|
||||||
name: memory
|
|
||||||
target:
|
|
||||||
type: Utilization
|
|
||||||
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
@@ -4,6 +4,7 @@ export default defineConfig({
|
|||||||
projectId: 'xkm1b4',
|
projectId: 'xkm1b4',
|
||||||
e2e: {
|
e2e: {
|
||||||
baseUrl: 'http://localhost:5055',
|
baseUrl: 'http://localhost:5055',
|
||||||
|
video: true,
|
||||||
experimentalSessionAndOrigin: true,
|
experimentalSessionAndOrigin: true,
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
|
|||||||
@@ -4,9 +4,8 @@
|
|||||||
"vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs",
|
"vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs",
|
||||||
"main": {
|
"main": {
|
||||||
"apiKey": "testkey",
|
"apiKey": "testkey",
|
||||||
"applicationTitle": "Overseerr",
|
"applicationTitle": "Jellyseerr",
|
||||||
"applicationUrl": "",
|
"applicationUrl": "",
|
||||||
"csrfProtection": false,
|
|
||||||
"cacheImages": false,
|
"cacheImages": false,
|
||||||
"defaultPermissions": 32,
|
"defaultPermissions": 32,
|
||||||
"defaultQuotas": {
|
"defaultQuotas": {
|
||||||
@@ -19,6 +18,8 @@
|
|||||||
"discoverRegion": "",
|
"discoverRegion": "",
|
||||||
"streamingRegion": "",
|
"streamingRegion": "",
|
||||||
"originalLanguage": "",
|
"originalLanguage": "",
|
||||||
|
"blacklistedTags": "",
|
||||||
|
"blacklistedTagsLimit": 50,
|
||||||
"trustProxy": false,
|
"trustProxy": false,
|
||||||
"mediaServerType": 1,
|
"mediaServerType": 1,
|
||||||
"partialRequestsEnabled": true,
|
"partialRequestsEnabled": true,
|
||||||
@@ -69,7 +70,7 @@
|
|||||||
"ignoreTls": false,
|
"ignoreTls": false,
|
||||||
"requireTls": false,
|
"requireTls": false,
|
||||||
"allowSelfSigned": false,
|
"allowSelfSigned": false,
|
||||||
"senderName": "Overseerr"
|
"senderName": "Jellyseerr"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"discord": {
|
"discord": {
|
||||||
@@ -81,13 +82,6 @@
|
|||||||
"enableMentions": true
|
"enableMentions": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lunasea": {
|
|
||||||
"enabled": false,
|
|
||||||
"types": 0,
|
|
||||||
"options": {
|
|
||||||
"webhookUrl": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"slack": {
|
"slack": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"types": 0,
|
"types": 0,
|
||||||
@@ -137,7 +131,16 @@
|
|||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"url": "",
|
"url": "",
|
||||||
"token": ""
|
"token": "",
|
||||||
|
"priority": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ntfy": {
|
||||||
|
"enabled": false,
|
||||||
|
"types": 0,
|
||||||
|
"options": {
|
||||||
|
"url": "",
|
||||||
|
"topic": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,5 +179,26 @@
|
|||||||
"image-cache-cleanup": {
|
"image-cache-cleanup": {
|
||||||
"schedule": "0 0 5 * * *"
|
"schedule": "0 0 5 * * *"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"csrfProtection": false,
|
||||||
|
"trustProxy": false,
|
||||||
|
"forceIpv4First": false,
|
||||||
|
"dnsServers": "",
|
||||||
|
"proxy": {
|
||||||
|
"enabled": false,
|
||||||
|
"hostname": "",
|
||||||
|
"port": 8080,
|
||||||
|
"useSsl": false,
|
||||||
|
"user": "",
|
||||||
|
"password": "",
|
||||||
|
"bypassFilter": "",
|
||||||
|
"bypassLocalAddresses": true
|
||||||
|
},
|
||||||
|
"dnsCache": {
|
||||||
|
"enabled": false,
|
||||||
|
"forceMinTtl": 0,
|
||||||
|
"forceMaxTtl": -1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
148
cypress/e2e/providers/tvdb.cy.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
describe('TVDB Integration', () => {
|
||||||
|
// Constants for routes and selectors
|
||||||
|
const ROUTES = {
|
||||||
|
home: '/',
|
||||||
|
metadataSettings: '/settings/metadata',
|
||||||
|
tomorrowIsOursTvShow: '/tv/72879',
|
||||||
|
monsterTvShow: '/tv/225634',
|
||||||
|
dragonnBallZKaiAnime: '/tv/61709',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SELECTORS = {
|
||||||
|
sidebarToggle: '[data-testid=sidebar-toggle]',
|
||||||
|
sidebarSettingsMobile: '[data-testid=sidebar-menu-settings-mobile]',
|
||||||
|
settingsNavDesktop: 'nav[data-testid="settings-nav-desktop"]',
|
||||||
|
metadataTestButton: 'button[type="button"]:contains("Test")',
|
||||||
|
metadataSaveButton: '[data-testid="metadata-save-button"]',
|
||||||
|
tmdbStatus: '[data-testid="tmdb-status"]',
|
||||||
|
tvdbStatus: '[data-testid="tvdb-status"]',
|
||||||
|
tvMetadataProviderSelector: '[data-testid="tv-metadata-provider-selector"]',
|
||||||
|
animeMetadataProviderSelector:
|
||||||
|
'[data-testid="anime-metadata-provider-selector"]',
|
||||||
|
seasonSelector: '[data-testid="season-selector"]',
|
||||||
|
season1: 'Season 1',
|
||||||
|
season2: 'Season 2',
|
||||||
|
season3: 'Season 3',
|
||||||
|
episodeList: '[data-testid="episode-list"]',
|
||||||
|
episode9: '9 - Hang Men',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reusable commands
|
||||||
|
const navigateToMetadataSettings = () => {
|
||||||
|
cy.visit(ROUTES.home);
|
||||||
|
cy.get(SELECTORS.sidebarToggle).click();
|
||||||
|
cy.get(SELECTORS.sidebarSettingsMobile).click();
|
||||||
|
cy.get(
|
||||||
|
`${SELECTORS.settingsNavDesktop} a[href="${ROUTES.metadataSettings}"]`
|
||||||
|
).click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const testAndVerifyMetadataConnection = () => {
|
||||||
|
cy.intercept('POST', '/api/v1/settings/metadatas/test').as(
|
||||||
|
'testConnection'
|
||||||
|
);
|
||||||
|
cy.get(SELECTORS.metadataTestButton).click();
|
||||||
|
return cy.wait('@testConnection');
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveMetadataSettings = (customBody = null) => {
|
||||||
|
if (customBody) {
|
||||||
|
cy.intercept('PUT', '/api/v1/settings/metadatas', (req) => {
|
||||||
|
req.body = customBody;
|
||||||
|
}).as('saveMetadata');
|
||||||
|
} else {
|
||||||
|
// Else just intercept without modifying body
|
||||||
|
cy.intercept('PUT', '/api/v1/settings/metadatas').as('saveMetadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
cy.get(SELECTORS.metadataSaveButton).click();
|
||||||
|
return cy.wait('@saveMetadata');
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Perform login
|
||||||
|
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
|
||||||
|
|
||||||
|
// Navigate to Metadata settings
|
||||||
|
navigateToMetadataSettings();
|
||||||
|
|
||||||
|
// Verify we're on the correct settings page
|
||||||
|
cy.contains('h3', 'Metadata Providers').should('be.visible');
|
||||||
|
|
||||||
|
// Configure TVDB as TV provider and test connection
|
||||||
|
cy.get(SELECTORS.tvMetadataProviderSelector).click();
|
||||||
|
|
||||||
|
// get id react-select-4-option-1
|
||||||
|
cy.get('[class*="react-select__option"]').contains('TheTVDB').click();
|
||||||
|
|
||||||
|
// Test the connection
|
||||||
|
testAndVerifyMetadataConnection().then(({ response }) => {
|
||||||
|
expect(response.statusCode).to.equal(200);
|
||||||
|
// Check TVDB connection status
|
||||||
|
cy.get(SELECTORS.tvdbStatus).should('contain', 'Operational');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save settings
|
||||||
|
saveMetadataSettings({
|
||||||
|
anime: 'tvdb',
|
||||||
|
tv: 'tvdb',
|
||||||
|
}).then(({ response }) => {
|
||||||
|
expect(response.statusCode).to.equal(200);
|
||||||
|
expect(response.body.tv).to.equal('tvdb');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display "Tomorrow is Ours" show information with multiple seasons from TVDB', () => {
|
||||||
|
// Navigate to the TV show
|
||||||
|
cy.visit(ROUTES.tomorrowIsOursTvShow);
|
||||||
|
|
||||||
|
// Verify that multiple seasons are displayed (TMDB has only 1 season, TVDB has multiple)
|
||||||
|
// cy.get(SELECTORS.seasonSelector).should('exist');
|
||||||
|
cy.intercept('/api/v1/tv/225634/season/1').as('season1');
|
||||||
|
// Select Season 2 and verify it loads
|
||||||
|
cy.contains(SELECTORS.season2)
|
||||||
|
.should('be.visible')
|
||||||
|
.scrollIntoView()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// Verify that episodes are displayed for Season 2
|
||||||
|
cy.contains('260 - Episode 506').should('be.visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should display "Monster" show information correctly when not existing on TVDB', () => {
|
||||||
|
// Navigate to the TV show
|
||||||
|
cy.visit(ROUTES.monsterTvShow);
|
||||||
|
|
||||||
|
// Intercept season 1 request
|
||||||
|
cy.intercept('/api/v1/tv/225634/season/1').as('season1');
|
||||||
|
|
||||||
|
// Select Season 1
|
||||||
|
cy.contains(SELECTORS.season1)
|
||||||
|
.should('be.visible')
|
||||||
|
.scrollIntoView()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// Wait for the season data to load
|
||||||
|
cy.wait('@season1');
|
||||||
|
|
||||||
|
// Verify specific episode exists
|
||||||
|
cy.contains(SELECTORS.episode9).should('be.visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display "Dragon Ball Z Kai" show information with multiple only 2 seasons from TVDB', () => {
|
||||||
|
// Navigate to the TV show
|
||||||
|
cy.visit(ROUTES.dragonnBallZKaiAnime);
|
||||||
|
|
||||||
|
// Intercept season 1 request
|
||||||
|
cy.intercept('/api/v1/tv/61709/season/1').as('season1');
|
||||||
|
|
||||||
|
// Select Season 2 and verify it visible
|
||||||
|
cy.contains(SELECTORS.season2)
|
||||||
|
.should('be.visible')
|
||||||
|
.scrollIntoView()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// select season 3 and verify it not visible
|
||||||
|
cy.contains(SELECTORS.season3).should('not.exist');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,10 +13,10 @@ describe('General Settings', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('modifies setting that requires restart', () => {
|
it('modifies setting that requires restart', () => {
|
||||||
cy.visit('/settings');
|
cy.visit('/settings/network');
|
||||||
|
|
||||||
cy.get('#trustProxy').click();
|
cy.get('#trustProxy').click();
|
||||||
cy.get('[data-testid=settings-main-form]').submit();
|
cy.get('[data-testid=settings-network-form]').submit();
|
||||||
cy.get('[data-testid=modal-title]').should(
|
cy.get('[data-testid=modal-title]').should(
|
||||||
'contain',
|
'contain',
|
||||||
'Server Restart Required'
|
'Server Restart Required'
|
||||||
@@ -26,7 +26,7 @@ describe('General Settings', () => {
|
|||||||
cy.get('[data-testid=modal-title]').should('not.exist');
|
cy.get('[data-testid=modal-title]').should('not.exist');
|
||||||
|
|
||||||
cy.get('[type=checkbox]#trustProxy').click();
|
cy.get('[type=checkbox]#trustProxy').click();
|
||||||
cy.get('[data-testid=settings-main-form]').submit();
|
cy.get('[data-testid=settings-network-form]').submit();
|
||||||
cy.get('[data-testid=modal-title]').should('not.exist');
|
cy.get('[data-testid=modal-title]').should('not.exist');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Cypress.Commands.add('login', (email, password) => {
|
|||||||
[email, password],
|
[email, password],
|
||||||
() => {
|
() => {
|
||||||
cy.visit('/login');
|
cy.visit('/login');
|
||||||
cy.contains('Use your Overseerr account').click();
|
|
||||||
|
|
||||||
cy.get('[data-testid=email]').type(email);
|
cy.get('[data-testid=email]').type(email);
|
||||||
cy.get('[data-testid=password]').type(password);
|
cy.get('[data-testid=password]').type(password);
|
||||||
|
|||||||
@@ -7,31 +7,34 @@ sidebar_position: 1
|
|||||||
|
|
||||||
Welcome to the Jellyseerr Documentation.
|
Welcome to the Jellyseerr Documentation.
|
||||||
|
|
||||||
|
**Jellyseerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Full Jellyfin/Emby/Plex integration**. Login and manage user access with Jellyfin/Emby/Plex.
|
- **Full Jellyfin/Emby/Plex integration**. Login and manage user access with Jellyfin/Emby/Plex.
|
||||||
- **Syncs to your Jellyfin/Emby/Plex library** to show what titles you already have.
|
- **Syncs to your Jellyfin/Emby/Plex library** to show what titles you already have.
|
||||||
|
- Supports Movies, Shows and Mixed Libraries.
|
||||||
- **Integrates with Sonarr and Radarr**. With more services to come in the future.
|
- **Integrates with Sonarr and Radarr**. With more services to come in the future.
|
||||||
|
- Optionally set **Override rules** for requests to match with your defined conditions.
|
||||||
- **Easy to use request system** allowing users to request individual seasons or movies in a friendly, clean UI.
|
- **Easy to use request system** allowing users to request individual seasons or movies in a friendly, clean UI.
|
||||||
- **Simple request management UI**. Don't dig through the app to approve recent requests.
|
- **Simple request management UI**. Don't dig through the app to approve recent requests.
|
||||||
- **Mobile-friendly design**, for when you need to approve requests on the go.
|
- **Mobile-friendly design**, for when you need to approve requests on the go.
|
||||||
- Granular permission system.
|
- Granular permission system.
|
||||||
- Localization into other languages.
|
- Localization into other languages.
|
||||||
- Support for PostgreSQL and SQLite databases.
|
- Support for **PostgreSQL** and **SQLite** databases.
|
||||||
|
- Support for various notification agents.
|
||||||
|
- Easily **Watchlist** or **Blacklist** media.
|
||||||
- More features to come!
|
- More features to come!
|
||||||
|
|
||||||
## Motivation
|
## Motivation
|
||||||
|
|
||||||
The primary motivation for starting this project was to add support for Jellyfin and Emby to Overseerr. As Overseerr is an incredibly performant and easy-to-use application, we wanted to bring that same experience to Jellyfin and Emby users. Thus, **Jellyseerr** was born.
|
The primary motivation for starting Jellyseerr was to bring Jellyfin and Emby support to Overseerr. However, over time, **Jellyseerr** has evolved into its own distinct application with unique features. Designed as a one-stop shop for media requests, it offers a simple, easy-to-use experience for managing requests on Jellyfin, Emby, and Plex servers.
|
||||||
|
|
||||||
This application is designed to be a **one-stop-shop** for all your media requests. It is designed to be a **simple, easy-to-use** application that allows users to request media to be added to your Jellyfin/Emby/Plex server.
|
|
||||||
|
|
||||||
## We need your help!
|
## We need your help!
|
||||||
|
|
||||||
[Jellyseerr](https://github.com/Fallenbagel/jellyseerr) is a fork of Overseerr, with a heavy focus on Jellyfin and Emby integration.
|
[Jellyseerr](https://github.com/Fallenbagel/jellyseerr) is an ambitious project where developers/contributors poured a lot of work into, and that builds on top of [Overseerr](https://github.com/sct/overseerr). And we have a lot more to do as well.
|
||||||
[Overseerr](https://github.com/sct/overseerr) is an ambitious project where the original developers/contributors have already poured a lot of work into, and we wanted to build on top of that.
|
|
||||||
|
|
||||||
We also have poured a lot of work into this project, and we have a lot more to do as well. We need your valuable feedback and help to find and fix bugs. Also, with Jellyseerr being an open-source project, anyone is welcome to contribute. We also encourage you to contribute to Overseerr as well.
|
We value your feedback and support in identifying and fixing bugs to make Jellyseerr even better. As an open-source project, we welcome contributions from everyone. While Jellyseerr has diverged from Overseerr and evolved into its own unique application, we still encourage contributions to Overseerr, as it played a crucial role in inspiring what Jellyseerr has become today.
|
||||||
|
|
||||||
Contribution includes building new features, patching bugs, translating the application, or even just writing documentation.
|
Contribution includes building new features, patching bugs, translating the application, or even just writing documentation.
|
||||||
|
|
||||||
|
|||||||
@@ -9,25 +9,66 @@ Jellyseerr supports SQLite and PostgreSQL. The database connection can be config
|
|||||||
|
|
||||||
## SQLite Options
|
## SQLite Options
|
||||||
|
|
||||||
|
If you want to use SQLite, you can simply set the `DB_TYPE` environment variable to `sqlite`. This is the default configuration so even if you don't set any other options, SQLite will be used.
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
DB_TYPE="sqlite" # Which DB engine to use, either "sqlite" or "postgres". The default is "sqlite".
|
DB_TYPE=sqlite # Which DB engine to use, either sqlite or postgres. The default is sqlite.
|
||||||
CONFIG_DIRECTORY="config" # (optional) The path to the config directory where the db file is stored. The default is "config".
|
CONFIG_DIRECTORY="config" # (optional) The path to the config directory where the db file is stored. The default is "config".
|
||||||
DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false".
|
DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false".
|
||||||
```
|
```
|
||||||
|
|
||||||
## PostgreSQL Options
|
## PostgreSQL Options
|
||||||
|
|
||||||
|
### TCP Connection
|
||||||
|
|
||||||
|
If your PostgreSQL server is configured to accept TCP connections, you can specify the host and port using the `DB_HOST` and `DB_PORT` environment variables. This is useful for remote connections where the server uses a network host and port.
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
DB_TYPE="postgres" # Which DB engine to use, either "sqlite" or "postgres". The default is "sqlite". To use postgres, this needs to be set to "postgres"
|
DB_TYPE=postgres # Which DB engine to use, either sqlite or postgres. The default is sqlite.
|
||||||
DB_HOST="localhost" # (optional) The host (url) of the database. The default is "localhost".
|
DB_HOST="localhost" # (optional) The host (URL) of the database. The default is "localhost".
|
||||||
DB_PORT="5432" # (optional) The port to connect to. The default is "5432".
|
DB_PORT="5432" # (optional) The port to connect to. The default is "5432".
|
||||||
DB_USER= # (required) Username used to connect to the database
|
DB_USER= # (required) Username used to connect to the database.
|
||||||
DB_PASS= # (required) Password of the user used to connect to the database
|
DB_PASS= # (required) Password of the user used to connect to the database.
|
||||||
DB_NAME="jellyseerr" # (optional) The name of the database to connect to. The default is "jellyseerr".
|
DB_NAME="jellyseerr" # (optional) The name of the database to connect to. The default is "jellyseerr".
|
||||||
DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false".
|
DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false".
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Unix Socket Connection
|
||||||
|
|
||||||
|
If your PostgreSQL server is configured to accept Unix socket connections, you can specify the path to the socket directory using the `DB_SOCKET_PATH` environment variable. This is useful for local connections where the server uses a Unix socket.
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
DB_TYPE=postgres # Which DB engine to use, either sqlite or postgres. The default is sqlite.
|
||||||
|
DB_SOCKET_PATH="/var/run/postgresql" # (required) The path to the PostgreSQL Unix socket directory.
|
||||||
|
DB_USER= # (required) Username used to connect to the database.
|
||||||
|
DB_PASS= # (optional) Password of the user used to connect to the database, depending on the server's authentication configuration.
|
||||||
|
DB_NAME="jellyseerr" # (optional) The name of the database to connect to. The default is "jellyseerr".
|
||||||
|
DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false".
|
||||||
|
```
|
||||||
|
|
||||||
|
:::info
|
||||||
|
**Finding Your PostgreSQL Socket Path**
|
||||||
|
|
||||||
|
The PostgreSQL socket path varies by operating system and installation method:
|
||||||
|
|
||||||
|
- **Ubuntu/Debian**: `/var/run/postgresql`
|
||||||
|
- **CentOS/RHEL/Fedora**: `/var/run/postgresql`
|
||||||
|
- **macOS (Homebrew)**: `/tmp` or `/opt/homebrew/var/postgresql`
|
||||||
|
- **macOS (Postgres.app)**: `/tmp`
|
||||||
|
- **Windows**: Not applicable (uses TCP connections)
|
||||||
|
|
||||||
|
You can find your socket path by running:
|
||||||
|
```bash
|
||||||
|
# Find PostgreSQL socket directory
|
||||||
|
find /tmp /var/run /run -name ".s.PGSQL.*" 2>/dev/null | head -1 | xargs dirname
|
||||||
|
|
||||||
|
# Or check PostgreSQL configuration
|
||||||
|
sudo -u postgres psql -c "SHOW unix_socket_directories;"
|
||||||
|
```
|
||||||
|
:::
|
||||||
|
|
||||||
### SSL configuration
|
### SSL configuration
|
||||||
|
|
||||||
The following options can be used to further configure ssl. Certificates can be provided as a string or a file path, with the string version taking precedence.
|
The following options can be used to further configure ssl. Certificates can be provided as a string or a file path, with the string version taking precedence.
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
@@ -36,26 +77,89 @@ DB_SSL_REJECT_UNAUTHORIZED="true" # (optional) Whether to reject ssl connections
|
|||||||
DB_SSL_CA= # (optional) The CA certificate to verify the connection, provided as a string. The default is "".
|
DB_SSL_CA= # (optional) The CA certificate to verify the connection, provided as a string. The default is "".
|
||||||
DB_SSL_CA_FILE= # (optional) The path to a CA certificate to verify the connection. The default is "".
|
DB_SSL_CA_FILE= # (optional) The path to a CA certificate to verify the connection. The default is "".
|
||||||
DB_SSL_KEY= # (optional) The private key for the connection in PEM format, provided as a string. The default is "".
|
DB_SSL_KEY= # (optional) The private key for the connection in PEM format, provided as a string. The default is "".
|
||||||
DB_SSL_KEY_FILE= # (optinal) Path to the private key for the connection in PEM format. The default is "".
|
DB_SSL_KEY_FILE= # (optional) Path to the private key for the connection in PEM format. The default is "".
|
||||||
DB_SSL_CERT= # (optional) Certificate chain in pem format for the private key, provided as a string. The default is "".
|
DB_SSL_CERT= # (optional) Certificate chain in pem format for the private key, provided as a string. The default is "".
|
||||||
DB_SSL_CERT_FILE= # (optional) Path to certificate chain in pem format for the private key. The default is "".
|
DB_SSL_CERT_FILE= # (optional) Path to certificate chain in pem format for the private key. The default is "".
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Migrating from SQLite to PostgreSQL
|
### Migrating from SQLite to PostgreSQL
|
||||||
|
|
||||||
1. Set up your PostgreSQL database and configure Jellyseerr to use it
|
1. Set up your PostgreSQL database and configure Jellyseerr to use it
|
||||||
2. Run Jellyseerr to create the tables in the PostgreSQL database
|
2. Run Jellyseerr to create the tables in the PostgreSQL database
|
||||||
3. Stop Jellyseerr
|
3. Stop Jellyseerr
|
||||||
4. Run the following command to export the data from the SQLite database and import it into the PostgreSQL database:
|
4. Run the following command to export the data from the SQLite database and import it into the PostgreSQL database:
|
||||||
|
|
||||||
:::info
|
:::info
|
||||||
Edit the postgres connection string to match your setup
|
Edit the postgres connection string (without the \{\{ and \}\} brackets) to match your setup.
|
||||||
|
|
||||||
If you don't have or don't want to use docker, you can build the working pgloader version [in this PR](https://github.com/dimitri/pgloader/pull/1531) from source and use the same options as below.
|
If you don't have or don't want to use docker, you can build the working pgloader version [in this PR](https://github.com/dimitri/pgloader/pull/1531) from source and use the same options as below.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
:::caution
|
:::caution
|
||||||
The most recent release of pgloader has an issue quoting the table columns. Use the version in the docker container to avoid this issue.
|
The most recent release of pgloader has an issue quoting the table columns. Use the version in the docker container to avoid this issue.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
import Tabs from '@theme/Tabs';
|
||||||
|
import TabItem from '@theme/TabItem';
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem value="docker" label="Using pgloader Container (Recommended)" default>
|
||||||
|
|
||||||
|
**Recommended method**: Use the pgloader container even for standalone Jellyseerr installations. This avoids building from source and ensures compatibility.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm -v config/db.sqlite3:/db.sqlite3:ro -v pgloader/pgloader.load:/pgloader.load ghcr.io/ralgar/pgloader:pr-1531 pgloader --with "quote identifiers" --with "data only" /db.sqlite3 postgresql://{{DB_USER}}:{{DB_PASS}}@{{DB_HOST}}:{{DB_PORT}}/{{DB_NAME}}
|
# For standalone installations (no Docker network needed)
|
||||||
```
|
docker run --rm \
|
||||||
|
-v /path/to/your/config/db.sqlite3:/db.sqlite3:ro \
|
||||||
|
ghcr.io/ralgar/pgloader:pr-1531 \
|
||||||
|
pgloader --with "quote identifiers" --with "data only" \
|
||||||
|
/db.sqlite3 postgresql://{{DB_USER}}:{{DB_PASS}}@{{DB_HOST}}:{{DB_PORT}}/{{DB_NAME}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**For Docker Compose setups**: Add the network parameter if your PostgreSQL is also in a container:
|
||||||
|
```bash
|
||||||
|
docker run --rm \
|
||||||
|
--network your-jellyseerr-network \
|
||||||
|
-v /path/to/your/config/db.sqlite3:/db.sqlite3:ro \
|
||||||
|
ghcr.io/ralgar/pgloader:pr-1531 \
|
||||||
|
pgloader --with "quote identifiers" --with "data only" \
|
||||||
|
/db.sqlite3 postgresql://{{DB_USER}}:{{DB_PASS}}@{{DB_HOST}}:{{DB_PORT}}/{{DB_NAME}}
|
||||||
|
```
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
<TabItem value="standalone" label="Building pgloader from Source">
|
||||||
|
|
||||||
|
For users who prefer not to use Docker or need a custom build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository and checkout the working version
|
||||||
|
git clone https://github.com/dimitri/pgloader.git
|
||||||
|
cd pgloader
|
||||||
|
git fetch origin pull/1531/head:pr-1531
|
||||||
|
git checkout pr-1531
|
||||||
|
|
||||||
|
# Follow the official installation instructions
|
||||||
|
# See: https://github.com/dimitri/pgloader/blob/master/INSTALL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
:::info
|
||||||
|
**Building pgloader from source requires following the complete installation process outlined in the [official pgloader INSTALL.md](https://github.com/dimitri/pgloader/blob/master/INSTALL.md).**
|
||||||
|
|
||||||
|
Please refer to the official documentation for detailed, up-to-date installation instructions.
|
||||||
|
:::
|
||||||
|
|
||||||
|
Once pgloader is built, run the migration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run migration (adjust path to your config directory)
|
||||||
|
./pgloader --with "quote identifiers" --with "data only" \
|
||||||
|
/path/to/your/config/db.sqlite3 \
|
||||||
|
postgresql://{{DB_USER}}:{{DB_PASS}}@{{DB_HOST}}:{{DB_PORT}}/{{DB_NAME}}
|
||||||
|
```
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
5. Start Jellyseerr
|
5. Start Jellyseerr
|
||||||
|
|||||||
@@ -207,3 +207,62 @@ labels:
|
|||||||
```
|
```
|
||||||
|
|
||||||
For more information, please refer to the [Traefik documentation](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/).
|
For more information, please refer to the [Traefik documentation](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/).
|
||||||
|
|
||||||
|
## Apache2 HTTP Server
|
||||||
|
|
||||||
|
<Tabs groupId="apache2-reverse-proxy" queryString>
|
||||||
|
<TabItem value="subdomain" label="Subdomain">
|
||||||
|
|
||||||
|
Add the following Location block to your existing Server configuration.
|
||||||
|
|
||||||
|
```apache
|
||||||
|
# Jellyseerr
|
||||||
|
ProxyPreserveHost On
|
||||||
|
ProxyPass / http://localhost:5055 retry=0 connectiontimeout=5 timeout=30 keepalive=on
|
||||||
|
ProxyPassReverse http://localhost:5055 /
|
||||||
|
RequestHeader set Connection ""
|
||||||
|
```
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
|
<TabItem value="subfolder" label="Subfolder">
|
||||||
|
|
||||||
|
:::warning
|
||||||
|
This Apache2 subfolder reverse proxy is an unsupported workaround, and only provided as an example. The filters may stop working when Jellyseerr is updated.
|
||||||
|
|
||||||
|
If you encounter any issues with Jellyseerr while using this workaround, we may ask you to try to reproduce the problem without the Apache2 proxy.
|
||||||
|
:::
|
||||||
|
|
||||||
|
Add the following Location block to your existing Server configuration.
|
||||||
|
|
||||||
|
```apache
|
||||||
|
# Jellyseerr
|
||||||
|
# We will use "/jellyseerr" as subfolder
|
||||||
|
# You can replace it with any that you like
|
||||||
|
<Location /jellyseerr>
|
||||||
|
ProxyPreserveHost On
|
||||||
|
ProxyPass http://localhost:5055 retry=0 connectiontimeout=5 timeout=30 keepalive=on
|
||||||
|
ProxyPassReverse http://localhost:5055
|
||||||
|
RequestHeader set Connection ""
|
||||||
|
|
||||||
|
# Header update, to support subfolder
|
||||||
|
# Please Replace "FQDN" with your domain
|
||||||
|
Header edit location ^/login https://FQDN/jellyseerr/login
|
||||||
|
Header edit location ^/setup https://FQDN/jellyseerr/setup
|
||||||
|
|
||||||
|
AddOutputFilterByType INFLATE;SUBSTITUTE text/html application/javascript application/json
|
||||||
|
SubstituteMaxLineLength 2000K
|
||||||
|
# This is HTML and JS update
|
||||||
|
# Please update "/jellyseerr" if needed
|
||||||
|
Substitute "s|href=\"|href=\"/jellyseerr|inq"
|
||||||
|
Substitute "s|src=\"|src=\"/jellyseerr|inq"
|
||||||
|
Substitute "s|/api/|/jellyseerr/api/|inq"
|
||||||
|
Substitute "s|\"/_next/|\"/jellyseerr/_next/|inq"
|
||||||
|
# This is JSON update
|
||||||
|
Substitute "s|\"/avatarproxy/|\"/jellyseerr/avatarproxy/|inq"
|
||||||
|
</Location>
|
||||||
|
```
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
|
</Tabs>
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ sidebar_position: 2
|
|||||||
# Build from Source (Advanced)
|
# Build from Source (Advanced)
|
||||||
:::warning
|
:::warning
|
||||||
This method is not recommended for most users. It is intended for advanced users who are familiar with managing their own server infrastructure.
|
This method is not recommended for most users. It is intended for advanced users who are familiar with managing their own server infrastructure.
|
||||||
|
|
||||||
|
Refer to [Configuring Databases](/extending-jellyseerr/database-config#postgresql-options) for details on how to configure your database.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
import Tabs from '@theme/Tabs';
|
import Tabs from '@theme/Tabs';
|
||||||
import TabItem from '@theme/TabItem';
|
import TabItem from '@theme/TabItem';
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- [Node.js 20.x](https://nodejs.org/en/download/)
|
- [Node.js 22.x](https://nodejs.org/en/download/)
|
||||||
- [Pnpm 9.x](https://pnpm.io/installation)
|
- [Pnpm 9.x](https://pnpm.io/installation)
|
||||||
- [Git](https://git-scm.com/downloads)
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
|
||||||
@@ -253,7 +255,8 @@ To run jellyseerr as a service:
|
|||||||
1. Download the [Non-Sucking Service Manager](https://nssm.cc/download)
|
1. Download the [Non-Sucking Service Manager](https://nssm.cc/download)
|
||||||
2. Install NSSM:
|
2. Install NSSM:
|
||||||
```powershell
|
```powershell
|
||||||
nssm install Jellyseerr "C:\Program Files\nodejs\node.exe" ["C:\jellyseerr\dist\index.js"]
|
nssm install Jellyseerr "C:\Program Files\nodejs\node.exe" "C:\jellyseerr\dist\index.js"
|
||||||
|
nssm set Jellyseerr AppDirectory "C:\jellyseerr"
|
||||||
nssm set Jellyseerr AppEnvironmentExtra NODE_ENV=production
|
nssm set Jellyseerr AppEnvironmentExtra NODE_ENV=production
|
||||||
```
|
```
|
||||||
3. Start the service:
|
3. Start the service:
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ sidebar_position: 1
|
|||||||
:::info
|
:::info
|
||||||
This is the recommended method for most users.
|
This is the recommended method for most users.
|
||||||
Details on how to install Docker can be found on the [official Docker website](https://docs.docker.com/get-docker/).
|
Details on how to install Docker can be found on the [official Docker website](https://docs.docker.com/get-docker/).
|
||||||
|
|
||||||
|
Refer to [Configuring Databases](/extending-jellyseerr/database-config#postgresql-options) for details on how to configure your database.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Unix (Linux, macOS)
|
## Unix (Linux, macOS)
|
||||||
@@ -31,17 +33,23 @@ docker run -d \
|
|||||||
--name jellyseerr \
|
--name jellyseerr \
|
||||||
-e LOG_LEVEL=debug \
|
-e LOG_LEVEL=debug \
|
||||||
-e TZ=Asia/Tashkent \
|
-e TZ=Asia/Tashkent \
|
||||||
-e PORT=5055 `#optional` \
|
-e PORT=5055 \
|
||||||
-p 5055:5055 \
|
-p 5055:5055 \
|
||||||
-v /path/to/appdata/config:/app/config \
|
-v /path/to/appdata/config:/app/config \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
fallenbagel/jellyseerr
|
fallenbagel/jellyseerr
|
||||||
```
|
```
|
||||||
:::tip
|
|
||||||
If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable to `emby`.
|
|
||||||
|
|
||||||
`-e JELLYFIN_TYPE=emby`
|
The argument `-e PORT=5055` is optional.
|
||||||
:::
|
|
||||||
|
If you want to add a healthcheck to the above command, you can add the following flags :
|
||||||
|
```
|
||||||
|
--health-cmd "wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1" \
|
||||||
|
--health-start-period 20s \
|
||||||
|
--health-timeout 3s \
|
||||||
|
--health-interval 15s \
|
||||||
|
--health-retries 3 \
|
||||||
|
```
|
||||||
|
|
||||||
To run the container as a specific user/group, you may optionally add `--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ]` to the above command.
|
To run the container as a specific user/group, you may optionally add `--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ]` to the above command.
|
||||||
|
|
||||||
@@ -49,7 +57,7 @@ To run the container as a specific user/group, you may optionally add `--user=[
|
|||||||
|
|
||||||
Stop and remove the existing container:
|
Stop and remove the existing container:
|
||||||
```bash
|
```bash
|
||||||
docker stop jellyseerr && docker rm Jellyseerr
|
docker stop jellyseerr && docker rm jellyseerr
|
||||||
```
|
```
|
||||||
Pull the latest image:
|
Pull the latest image:
|
||||||
```bash
|
```bash
|
||||||
@@ -86,11 +94,14 @@ services:
|
|||||||
- 5055:5055
|
- 5055:5055
|
||||||
volumes:
|
volumes:
|
||||||
- /path/to/appdata/config:/app/config
|
- /path/to/appdata/config:/app/config
|
||||||
|
healthcheck:
|
||||||
|
test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1
|
||||||
|
start_period: 20s
|
||||||
|
timeout: 3s
|
||||||
|
interval: 15s
|
||||||
|
retries: 3
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
:::tip
|
|
||||||
If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable to `emby`.
|
|
||||||
:::
|
|
||||||
|
|
||||||
Then, start all services defined in the Compose file:
|
Then, start all services defined in the Compose file:
|
||||||
```bash
|
```bash
|
||||||
@@ -119,8 +130,7 @@ You may alternatively use a third-party mechanism like [dockge](https://github.c
|
|||||||
2. Inside the **Community Applications** app store, search for **Jellyseerr**.
|
2. Inside the **Community Applications** app store, search for **Jellyseerr**.
|
||||||
3. Click the **Install Button**.
|
3. Click the **Install Button**.
|
||||||
4. On the following **Add Container** screen, make changes to the **Host Port** and **Host Path 1** \(Appdata\) as needed.
|
4. On the following **Add Container** screen, make changes to the **Host Port** and **Host Path 1** \(Appdata\) as needed.
|
||||||
5. If you want to use emby, make sure to set the `JELLYFIN_TYPE` environment variable to `emby`. Otherwise, remove the variable.
|
5. Click apply and access "Jellyseerr" at your `<ServerIP:HostPort>` in a web browser.
|
||||||
6. Click apply and access "Jellyseerr" at your `<ServerIP:HostPort>` in a web browser.
|
|
||||||
|
|
||||||
## Windows
|
## Windows
|
||||||
|
|
||||||
@@ -144,7 +154,26 @@ Then, create and start the Jellyseerr container:
|
|||||||
<Tabs groupId="docker-methods" queryString>
|
<Tabs groupId="docker-methods" queryString>
|
||||||
<TabItem value="docker-cli" label="Docker CLI">
|
<TabItem value="docker-cli" label="Docker CLI">
|
||||||
```bash
|
```bash
|
||||||
docker run -d --name jellyseerr -e LOG_LEVEL=debug -e TZ=Asia/Tashkent -p 5055:5055 -v "jellyseerr-data:/app/config" --restart unless-stopped fallenbagel/jellyseerr:latest
|
docker run -d \
|
||||||
|
--name jellyseerr \
|
||||||
|
-e LOG_LEVEL=debug \
|
||||||
|
-e TZ=Asia/Tashkent \
|
||||||
|
-e PORT=5055 \
|
||||||
|
-p 5055:5055 \
|
||||||
|
-v jellyseerr-data:/app/config \
|
||||||
|
--restart unless-stopped \
|
||||||
|
fallenbagel/jellyseerr
|
||||||
|
```
|
||||||
|
|
||||||
|
The argument `-e PORT=5055` is optional.
|
||||||
|
|
||||||
|
If you want to add a healthcheck to the above command, you can add the following flags :
|
||||||
|
```
|
||||||
|
--health-cmd "wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1" \
|
||||||
|
--health-start-period 20s \
|
||||||
|
--health-timeout 3s \
|
||||||
|
--health-interval 15s \
|
||||||
|
--health-retries 3 \
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Updating:
|
#### Updating:
|
||||||
@@ -172,6 +201,12 @@ services:
|
|||||||
- 5055:5055
|
- 5055:5055
|
||||||
volumes:
|
volumes:
|
||||||
- jellyseerr-data:/app/config
|
- jellyseerr-data:/app/config
|
||||||
|
healthcheck:
|
||||||
|
test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1
|
||||||
|
start_period: 20s
|
||||||
|
timeout: 3s
|
||||||
|
interval: 15s
|
||||||
|
retries: 3
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
@@ -191,12 +226,6 @@ docker compose up -d
|
|||||||
</TabItem>
|
</TabItem>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
:::tip
|
|
||||||
If you are using a named volume, then you can safely **ignore** the warning about the `/app/config` folder being incorrectly mounted.
|
|
||||||
|
|
||||||
If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable to `emby`.
|
|
||||||
:::
|
|
||||||
|
|
||||||
To access the files inside the volume created above, navigate to `\\wsl$\docker-desktop-data\version-pack-data\community\docker\volumes\jellyseerr-data\_data` using File Explorer.
|
To access the files inside the volume created above, navigate to `\\wsl$\docker-desktop-data\version-pack-data\community\docker\volumes\jellyseerr-data\_data` using File Explorer.
|
||||||
|
|
||||||
:::info
|
:::info
|
||||||
|
|||||||
21
docs/getting-started/kubernetes.mdx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
title: Kubernetes
|
||||||
|
description: Install Jellyseerr in Kubernetes
|
||||||
|
sidebar_position: 5
|
||||||
|
---
|
||||||
|
# Kubernetes
|
||||||
|
:::info
|
||||||
|
This method is not recommended for most users. It is intended for advanced users who are using Kubernetes.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
```console
|
||||||
|
helm install jellyseerr oci://ghcr.io/fallenbagel/jellyseerr/jellyseerr-chart
|
||||||
|
```
|
||||||
|
Helm values can be found in the Jellyseerr repository under [charts/jellyseerr-chart/README.md](https://github.com/Fallenbagel/jellyseerr/tree/develop/charts/jellyseerr-chart).
|
||||||
|
|
||||||
|
Verify the signature with [cosign](https://docs.sigstore.dev/cosign/system_config/installation/) (replace [tag], with the TAG you want to verify) :
|
||||||
|
```console
|
||||||
|
cosign verify ghcr.io/fallenbagel/jellyseerr/jellyseerr-chart:[tag] --certificate-identity=https://github.com/Fallenbagel/jellyseerr/.github/workflows/helm.yml@refs/heads/main --certificate-oidc-issuer=https://token.ac
|
||||||
|
tions.githubusercontent.com
|
||||||
|
```
|
||||||
@@ -6,6 +6,8 @@ sidebar_position: 3
|
|||||||
|
|
||||||
import { JellyseerrVersion, NixpkgVersion } from '@site/src/components/JellyseerrVersion';
|
import { JellyseerrVersion, NixpkgVersion } from '@site/src/components/JellyseerrVersion';
|
||||||
import Admonition from '@theme/Admonition';
|
import Admonition from '@theme/Admonition';
|
||||||
|
import Tabs from '@theme/Tabs';
|
||||||
|
import TabItem from '@theme/TabItem';
|
||||||
|
|
||||||
# Nix Package Manager (Advanced)
|
# Nix Package Manager (Advanced)
|
||||||
:::info
|
:::info
|
||||||
@@ -13,22 +15,55 @@ This method is not recommended for most users. It is intended for advanced users
|
|||||||
:::
|
:::
|
||||||
|
|
||||||
export const VersionMismatchWarning = () => {
|
export const VersionMismatchWarning = () => {
|
||||||
const jellyseerrVersion = JellyseerrVersion();
|
let jellyseerrVersion = null;
|
||||||
const nixpkgVersion = NixpkgVersion();
|
let nixpkgVersions = null;
|
||||||
|
try {
|
||||||
|
jellyseerrVersion = JellyseerrVersion();
|
||||||
|
nixpkgVersions = NixpkgVersion();
|
||||||
|
} catch (err) {
|
||||||
|
return (
|
||||||
|
<Admonition type="error">
|
||||||
|
Failed to load version information. Error: {err.message || JSON.stringify(err)}
|
||||||
|
</Admonition>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const isUpToDate = jellyseerrVersion === nixpkgVersion;
|
if (!nixpkgVersions || nixpkgVersions.error) {
|
||||||
|
return (
|
||||||
|
<Admonition type="error">
|
||||||
|
Failed to fetch Nixpkg versions: {nixpkgVersions?.error || 'Unknown error'}
|
||||||
|
</Admonition>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUnstableUpToDate = jellyseerrVersion === nixpkgVersions.unstable;
|
||||||
|
const isStableUpToDate = jellyseerrVersion === nixpkgVersions.stable;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!isUpToDate ? (
|
{!isStableUpToDate ? (
|
||||||
<Admonition type="warning">
|
<Admonition type="warning">
|
||||||
The <a href="https://github.com/NixOS/nixpkgs/blob/nixos-unstable/pkgs/servers/jellyseerr/default.nix#L14">upstream Jellyseerr Nix Package (v{nixpkgVersion})</a> is not <b>up-to-date</b>. If you want to use <b>Jellyseerr v{jellyseerrVersion}</b>, you will need to <a href="#overriding-the-package-derivation">override the package derivation</a>.
|
The{' '}
|
||||||
</Admonition>
|
<a href="https://github.com/NixOS/nixpkgs/blob/nixos-24.11/pkgs/servers/jellyseerr/default.nix#L14">
|
||||||
) : (
|
upstream Jellyseerr Nix Package (v{nixpkgVersions.stable})
|
||||||
<Admonition type="success">
|
</a>{' '}
|
||||||
The <a href="https://github.com/NixOS/nixpkgs/blob/nixos-unstable/pkgs/servers/jellyseerr/default.nix#L14">upstream Jellyseerr Nix Package (v{nixpkgVersion})</a> is <b>up-to-date</b> with <b>Jellyseerr v{jellyseerrVersion}</b>.
|
is not <b>up-to-date</b>. If you want to use <b>Jellyseerr v{jellyseerrVersion}</b>,{' '}
|
||||||
</Admonition>
|
{isUnstableUpToDate ? (
|
||||||
)}
|
<>
|
||||||
|
consider using the{' '}
|
||||||
|
<a href="https://github.com/NixOS/nixpkgs/blob/nixos-unstable/pkgs/by-name/je/jellyseerr/package.nix">
|
||||||
|
unstable package
|
||||||
|
</a>{' '}
|
||||||
|
instead.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
you will need to{' '}
|
||||||
|
<a href="#overriding-the-package-derivation">override the package derivation</a>.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Admonition>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -48,6 +83,8 @@ To get up and running with jellyseerr using Nix, you can add the following to yo
|
|||||||
|
|
||||||
If you want more advanced configuration options, you can use the following:
|
If you want more advanced configuration options, you can use the following:
|
||||||
|
|
||||||
|
<Tabs groupId="nixpkg-methods" queryString>
|
||||||
|
<TabItem value="default" label="Default Configurations">
|
||||||
```nix
|
```nix
|
||||||
{ config, pkgs, ... }:
|
{ config, pkgs, ... }:
|
||||||
|
|
||||||
@@ -56,53 +93,20 @@ If you want more advanced configuration options, you can use the following:
|
|||||||
enable = true;
|
enable = true;
|
||||||
port = 5055;
|
port = 5055;
|
||||||
openFirewall = true;
|
openFirewall = true;
|
||||||
|
package = pkgs.jellyseerr; # Use the unstable package if stable is not up-to-date
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
</TabItem>
|
||||||
After adding the configuration to your `configuration.nix`, you can run the following command to install jellyseerr:
|
<TabItem value="custom" label="Database Configurations">
|
||||||
|
In order to use postgres, you will need to add override the default module of jellyseerr with the following as the current default module is not compatible with postgres:
|
||||||
```bash
|
```nix
|
||||||
nixos-rebuild switch
|
|
||||||
```
|
|
||||||
After rebuild is complete jellyseerr should be running, verify that it is with the following command.
|
|
||||||
```bash
|
|
||||||
systemctl status jellyseerr
|
|
||||||
```
|
|
||||||
|
|
||||||
:::info
|
|
||||||
You can now access Jellyseerr by visiting `http://localhost:5055` in your web browser.
|
|
||||||
:::
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import CodeBlock from '@theme/CodeBlock';
|
|
||||||
|
|
||||||
## Overriding the package derivation
|
|
||||||
export const VersionMatch = () => {
|
|
||||||
const jellyseerrVersion = JellyseerrVersion();
|
|
||||||
const nixpkgVersion = NixpkgVersion();
|
|
||||||
|
|
||||||
const code = `{ config, pkgs, ... }:
|
|
||||||
{
|
{
|
||||||
nixpkgs.config.packageOverrides = pkgs: {
|
config,
|
||||||
jellyseerr = pkgs.jellyseerr.overrideAttrs (oldAttrs: rec {
|
pkgs,
|
||||||
version = "${jellyseerrVersion}";
|
lib,
|
||||||
|
...
|
||||||
src = pkgs.fetchFromGitHub {
|
}:
|
||||||
rev = "v\${version}";
|
|
||||||
sha256 = pkgs.lib.fakeSha256;
|
|
||||||
};
|
|
||||||
|
|
||||||
offlineCache = pkgs.fetchYarnDeps {
|
|
||||||
sha256 = pkgs.lib.fakeSha256;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const module = `{ config, pkgs, lib, ... }:
|
|
||||||
|
|
||||||
with lib;
|
with lib;
|
||||||
let
|
let
|
||||||
cfg = config.services.jellyseerr;
|
cfg = config.services.jellyseerr;
|
||||||
@@ -113,28 +117,65 @@ in
|
|||||||
disabledModules = [ "services/misc/jellyseerr.nix" ];
|
disabledModules = [ "services/misc/jellyseerr.nix" ];
|
||||||
|
|
||||||
options.services.jellyseerr = {
|
options.services.jellyseerr = {
|
||||||
enable = mkEnableOption (mdDoc ''Jellyseerr, a requests manager for Jellyfin'');
|
enable = mkEnableOption ''Jellyseerr, a requests manager for Jellyfin'';
|
||||||
|
|
||||||
openFirewall = mkOption {
|
openFirewall = mkOption {
|
||||||
type = types.bool;
|
type = types.bool;
|
||||||
default = false;
|
default = false;
|
||||||
description = mdDoc ''Open port in the firewall for the Jellyseerr web interface.'';
|
description = ''Open port in the firewall for the Jellyseerr web interface.'';
|
||||||
};
|
};
|
||||||
|
|
||||||
port = mkOption {
|
port = mkOption {
|
||||||
type = types.port;
|
type = types.port;
|
||||||
default = 5055;
|
default = 5055;
|
||||||
description = mdDoc ''The port which the Jellyseerr web UI should listen to.'';
|
description = ''The port which the Jellyseerr web UI should listen to.'';
|
||||||
};
|
};
|
||||||
|
|
||||||
package = mkOption {
|
package = mkOption {
|
||||||
type = types.package;
|
type = types.package;
|
||||||
default = pkgs.jellyseerr;
|
default = pkgs.jellyseerr;
|
||||||
defaultText = literalExpression "pkgs.jellyseerr";
|
defaultText = literalExpression "pkgs.jellyseerr";
|
||||||
description = lib.mdDoc ''
|
description = ''
|
||||||
Jellyseerr package to use.
|
Jellyseerr package to use.
|
||||||
'';
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
databaseConfig = mkOption {
|
||||||
|
type = types.attrsOf types.str;
|
||||||
|
default = {
|
||||||
|
type = "sqlite";
|
||||||
|
configDirectory = "config";
|
||||||
|
logQueries = "false";
|
||||||
};
|
};
|
||||||
|
description = ''
|
||||||
|
Database configuration. For "sqlite", only "type", "configDirectory", and "logQueries" are relevant.
|
||||||
|
For "postgres", include host, port, user, pass, name, and optionally socket.
|
||||||
|
Example:
|
||||||
|
{
|
||||||
|
type = "postgres";
|
||||||
|
socket = "/run/postgresql";
|
||||||
|
user = "jellyseerr";
|
||||||
|
name = "jellyseerr";
|
||||||
|
logQueries = "false";
|
||||||
|
}
|
||||||
|
or
|
||||||
|
{
|
||||||
|
type = "postgres";
|
||||||
|
host = "localhost";
|
||||||
|
port = "5432";
|
||||||
|
user = "dbuser";
|
||||||
|
pass = "password";
|
||||||
|
name = "jellyseerr";
|
||||||
|
logQueries = "false";
|
||||||
|
}
|
||||||
|
or
|
||||||
|
{
|
||||||
|
type = "sqlite";
|
||||||
|
configDirectory = "config";
|
||||||
|
logQueries = "false";
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
config = mkIf cfg.enable {
|
||||||
@@ -142,14 +183,29 @@ in
|
|||||||
description = "Jellyseerr, a requests manager for Jellyfin";
|
description = "Jellyseerr, a requests manager for Jellyfin";
|
||||||
after = [ "network.target" ];
|
after = [ "network.target" ];
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
environment.PORT = toString cfg.port;
|
environment =
|
||||||
|
let
|
||||||
|
dbConfig = cfg.databaseConfig;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
PORT = toString cfg.port;
|
||||||
|
DB_TYPE = toString dbConfig.type;
|
||||||
|
CONFIG_DIRECTORY = toString dbConfig.configDirectory or "";
|
||||||
|
DB_LOG_QUERIES = toString dbConfig.logQueries;
|
||||||
|
DB_HOST = if dbConfig.type == "postgres" && !(hasAttr "socket" dbConfig) then toString dbConfig.host or "" else "";
|
||||||
|
DB_PORT = if dbConfig.type == "postgres" && !(hasAttr "socket" dbConfig) then toString dbConfig.port or "" else "";
|
||||||
|
DB_SOCKET_PATH = if dbConfig.type == "postgres" && hasAttr "socket" dbConfig then toString dbConfig.socket or "" else "";
|
||||||
|
DB_USER = if dbConfig.type == "postgres" then toString dbConfig.user or "" else "";
|
||||||
|
DB_PASS = if dbConfig.type == "postgres" then toString dbConfig.pass or "" else "";
|
||||||
|
DB_NAME = if dbConfig.type == "postgres" then toString dbConfig.name or "" else "";
|
||||||
|
};
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "exec";
|
Type = "exec";
|
||||||
StateDirectory = "jellyseerr";
|
StateDirectory = "jellyseerr";
|
||||||
WorkingDirectory = "\${cfg.package}/libexec/jellyseerr/deps/jellyseerr";
|
WorkingDirectory = "${cfg.package}/libexec/jellyseerr";
|
||||||
DynamicUser = true;
|
DynamicUser = true;
|
||||||
ExecStart = "\${cfg.package}/bin/jellyseerr";
|
ExecStart = "${cfg.package}/bin/jellyseerr";
|
||||||
BindPaths = [ "/var/lib/jellyseerr/:\${cfg.package}/libexec/jellyseerr/deps/jellyseerr/config/" ];
|
BindPaths = [ "/var/lib/jellyseerr/:${cfg.package}/libexec/jellyseerr/config/" ];
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
ProtectHome = true;
|
ProtectHome = true;
|
||||||
ProtectSystem = "strict";
|
ProtectSystem = "strict";
|
||||||
@@ -169,57 +225,47 @@ in
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
networking.firewall = mkIf cfg.openFirewall {
|
networking.firewall = mkIf cfg.openFirewall { allowedTCPPorts = [ cfg.port ]; };
|
||||||
allowedTCPPorts = [ cfg.port ];
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}`;
|
}
|
||||||
|
```
|
||||||
const configuration = `{ config, pkgs, ... }:
|
Then, import the module into your `configuration.nix`:
|
||||||
|
```nix
|
||||||
|
{ config, pkgs, ... }:
|
||||||
{
|
{
|
||||||
imports = [ ./jellyseerr-module.nix ]
|
imports = [ ./modules/jellyseerr.nix ];
|
||||||
|
|
||||||
services.jellyseerr = {
|
services.jellyseerr = {
|
||||||
enable = true;
|
enable = true;
|
||||||
port = 5055;
|
port = 5055;
|
||||||
openFirewall = true;
|
openFirewall = true;
|
||||||
package = (pkgs.callPackage (import ../../../pkgs/jellyseerr) { });
|
package = pkgs.unstable.jellyseerr; # use the unstable package if stable is not up-to-date
|
||||||
|
databaseConfig = {
|
||||||
|
type = "postgres";
|
||||||
|
host = "localhost"; # or socket: "/run/postgresql"
|
||||||
|
port = "5432"; # if using socket, this is not needed
|
||||||
|
user = "jellyseerr";
|
||||||
|
pass = "jellyseerr";
|
||||||
|
name = "jellyseerr";
|
||||||
|
logQueries = "false";
|
||||||
};
|
};
|
||||||
}`;
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
const isUpToDate = jellyseerrVersion === nixpkgVersion;
|
After adding the configuration to your `configuration.nix`, you can run the following command to install jellyseerr:
|
||||||
|
|
||||||
return (
|
```bash
|
||||||
<>
|
nixos-rebuild switch
|
||||||
{isUpToDate ? (
|
```
|
||||||
<>
|
After rebuild is complete jellyseerr should be running, verify that it is with the following command.
|
||||||
<p>The latest version of Jellyseerr <strong>({jellyseerrVersion})</strong> and the Jellyseerr nixpkg package version <strong>({nixpkgVersion})</strong> is <strong>up-to-date</strong>.</p>
|
```bash
|
||||||
<p>There is no need to override the package derivation.</p>
|
systemctl status jellyseerr
|
||||||
</>
|
```
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p>The latest version of Jellyseerr <strong>({jellyseerrVersion})</strong> and the Jellyseerr nixpkg version <strong>(v{nixpkgVersion})</strong> is <strong>out-of-date</strong>.
|
|
||||||
If you want to use <b>Jellyseerr v{jellyseerrVersion}</b>, you will need to override the package derivation.</p>
|
|
||||||
<p>In order to override the package derivation:</p>
|
|
||||||
<ol>
|
|
||||||
<li style={{ marginBottom: '1rem' }}>Grab the <a href="https://raw.githubusercontent.com/NixOS/nixpkgs/nixos-unstable/pkgs/servers/jellyseerr/default.nix">latest nixpkg derivation for Jellyseerr</a></li>
|
|
||||||
<li style={{ marginBottom: '1rem' }}>Grab the latest <a href="https://raw.githubusercontent.com/Fallenbagel/jellyseerr/main/package.json">package.json</a> for Jellyseerr</li>
|
|
||||||
<li style={{ marginBottom: '1rem' }}>Add it to the same directory as the nixpkg derivation</li>
|
|
||||||
<li style={{ marginBottom: '1rem' }}>Update the `src` and `offlineCache` attributes in the nixpkg derivation:</li>
|
|
||||||
<CodeBlock className="language-nix" style={{ marginBottom: '1rem' }}>{code}</CodeBlock>
|
|
||||||
<Admonition type="tip" style={{ marginBottom: '1rem' }}>You can replace the <b>sha256</b> with the actual hash that <b>nixos-rebuild</b> outputs when you run the command.</Admonition>
|
|
||||||
<li style={{ marginBottom: '1rem' }}>Grab this module and import it in your `configuration.nix`</li>
|
|
||||||
<CodeBlock className="language-nix" style={{ marginBottom: '1rem' }}>{module}</CodeBlock>
|
|
||||||
<Admonition type="tip" style={{ marginBottom: '1rem' }}>We are using a custom module because the upstream module does not have a package option.</Admonition>
|
|
||||||
<li style={{ marginBottom: '1rem' }}>Call the new package in your `configuration.nix`</li>
|
|
||||||
<CodeBlock className="language-nix" style={{ marginBottom: '1rem' }}>{configuration}</CodeBlock>
|
|
||||||
</ol>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
};
|
:::info
|
||||||
|
You can now access Jellyseerr by visiting `http://localhost:5055` in your web browser.
|
||||||
<VersionMatch />
|
:::
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ or for Cloudflare's DNS:
|
|||||||
```bash
|
```bash
|
||||||
--dns=1.1.1.1
|
--dns=1.1.1.1
|
||||||
```
|
```
|
||||||
|
or for Quad9 DNS:
|
||||||
|
```bash
|
||||||
|
--dns=9.9.9.9
|
||||||
|
```
|
||||||
|
|
||||||
|
You can try them all and see which one works for your network.
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
@@ -45,6 +51,16 @@ services:
|
|||||||
dns:
|
dns:
|
||||||
- 1.1.1.1
|
- 1.1.1.1
|
||||||
```
|
```
|
||||||
|
or for Quad9's DNS:
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
services:
|
||||||
|
jellyseerr:
|
||||||
|
dns:
|
||||||
|
- 9.9.9.9
|
||||||
|
```
|
||||||
|
|
||||||
|
You can try them all and see which one works for your network.
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
@@ -56,7 +72,7 @@ services:
|
|||||||
4. Click on Change adapter settings.
|
4. Click on Change adapter settings.
|
||||||
5. Right-click the network interface connected to the internet and select Properties.
|
5. Right-click the network interface connected to the internet and select Properties.
|
||||||
6. Select Internet Protocol Version 4 (TCP/IPv4) and click Properties.
|
6. Select Internet Protocol Version 4 (TCP/IPv4) and click Properties.
|
||||||
7. Select Use the following DNS server addresses and enter `8.8.8.8` for Google's DNS or `1.1.1.1` for Cloudflare's DNS.
|
7. Select Use the following DNS server addresses and enter `8.8.8.8` for Google's DNS or `1.1.1.1` for Cloudflare's DNS or `9.9.9.9` for Quad9's DNS.
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
@@ -73,41 +89,15 @@ services:
|
|||||||
```bash
|
```bash
|
||||||
nameserver 1.1.1.1
|
nameserver 1.1.1.1
|
||||||
```
|
```
|
||||||
|
or for Quad9's DNS:
|
||||||
|
```bash
|
||||||
|
nameserver 9.9.9.9
|
||||||
|
```
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
### Option 2: Force IPV4 resolution first
|
### Option 2: Use Jellyseerr through a proxy
|
||||||
|
|
||||||
Sometimes there are configuration issues with IPV6 that prevent the hostname resolution from working correctly.
|
|
||||||
|
|
||||||
You can try to force the resolution to use IPV4 first by setting the `FORCE_IPV4_FIRST` environment variable to `true`:
|
|
||||||
|
|
||||||
<Tabs groupId="methods" queryString>
|
|
||||||
<TabItem value="docker-cli" label="Docker CLI">
|
|
||||||
|
|
||||||
Add the following to your `docker run` command:
|
|
||||||
```bash
|
|
||||||
-e "FORCE_IPV4_FIRST=true"
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
|
|
||||||
<TabItem value="docker-compose" label="Docker Compose">
|
|
||||||
|
|
||||||
Add the following to your `compose.yaml`:
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
services:
|
|
||||||
jellyseerr:
|
|
||||||
environment:
|
|
||||||
- FORCE_IPV4_FIRST=true
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
### Option 3: Use Jellyseerr through a proxy
|
|
||||||
|
|
||||||
If you can't change your DNS servers or force IPV4 resolution, you can use Jellyseerr through a proxy.
|
If you can't change your DNS servers or force IPV4 resolution, you can use Jellyseerr through a proxy.
|
||||||
|
|
||||||
@@ -115,6 +105,12 @@ In some places (like China), the ISP blocks not only the DNS resolution but also
|
|||||||
|
|
||||||
You can configure Jellyseerr to use a proxy with the [HTTP(S) Proxy](/using-jellyseerr/settings/general#https-proxy) setting.
|
You can configure Jellyseerr to use a proxy with the [HTTP(S) Proxy](/using-jellyseerr/settings/general#https-proxy) setting.
|
||||||
|
|
||||||
|
### Option 3: Force IPV4 resolution first
|
||||||
|
|
||||||
|
Sometimes there are configuration issues with IPV6 that prevent the hostname resolution from working correctly.
|
||||||
|
|
||||||
|
You can try to force the resolution to use IPV4 first by going to `Settings > Networking > Advanced Networking` and enabling `Force IPv4 Resolution First` setting and restarting Jellyseerr.
|
||||||
|
|
||||||
### Option 4: Check that your server can reach TMDB API
|
### Option 4: Check that your server can reach TMDB API
|
||||||
|
|
||||||
Make sure that your server can reach the TMDB API by running the following command:
|
Make sure that your server can reach the TMDB API by running the following command:
|
||||||
@@ -156,3 +152,26 @@ In a PowerShell window:
|
|||||||
|
|
||||||
If you can't get a response, then your server can't reach the TMDB API.
|
If you can't get a response, then your server can't reach the TMDB API.
|
||||||
This is usually due to a network configuration issue or a firewall blocking the connection.
|
This is usually due to a network configuration issue or a firewall blocking the connection.
|
||||||
|
|
||||||
|
## Account does not have admin privileges
|
||||||
|
|
||||||
|
If your admin account no longer has admin privileges, this is typically because your Jellyfin/Emby user ID has changed on the server side.
|
||||||
|
|
||||||
|
This can happen if you have a new installation of Jellyfin/Emby or if you have changed the user ID of your admin account.
|
||||||
|
|
||||||
|
### Solution: Reset admin access
|
||||||
|
|
||||||
|
1. Back up your `settings.json` file (located in your Jellyseerr data directory)
|
||||||
|
2. Stop the Jellyseerr container/service
|
||||||
|
3. Delete the `settings.json` file
|
||||||
|
4. Start Jellyseerr again
|
||||||
|
5. This will force the setup page to appear
|
||||||
|
6. Go through the setup process with the same login details
|
||||||
|
7. You can skip the services setup
|
||||||
|
8. Once you reach the discover page, stop Jellyseerr
|
||||||
|
9. Restore your backed-up `settings.json` file
|
||||||
|
10. Start Jellyseerr again
|
||||||
|
|
||||||
|
This process should restore your admin privileges while preserving your settings.
|
||||||
|
|
||||||
|
If you still encounter issues, please reach out on our support channels.
|
||||||
|
|||||||
93
docs/using-jellyseerr/backups.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
title: Backups
|
||||||
|
description: Understand which data you should back up.
|
||||||
|
sidebar_position: 4
|
||||||
|
---
|
||||||
|
|
||||||
|
# Which data does Jellyseerr save and where?
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
All configurations from the **Settings** panel in the Jellyseerr web UI are saved, including integrations with Radarr, Sonarr, Jellyfin, Plex, and notification settings.
|
||||||
|
These settings are stored in the `settings.json` file located in the Jellyseerr data folder.
|
||||||
|
|
||||||
|
## User Data
|
||||||
|
|
||||||
|
Apart from the settings, all other data—including user accounts, media requests, blacklist etc. are stored in the database (either SQLite or PostgreSQL).
|
||||||
|
|
||||||
|
# Backup
|
||||||
|
|
||||||
|
### SQLite
|
||||||
|
|
||||||
|
If your backup system uses filesystem snapshots (such as Kubernetes with Volsync), you can directly back up the Jellyseerr data folder.
|
||||||
|
Otherwise, you need to stop the Jellyseerr application and back up the `config` folder.
|
||||||
|
|
||||||
|
For advanced users, it's possible to back up the database without stopping the application by using the [SQLite CLI](https://www.sqlite.org/download.html). Run the following command to create a backup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 db/db.sqlite3 ".backup '/tmp/jellyseerr_db.sqlite3.bak'"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, copy the `/tmp/jellyseerr_dump.sqlite3.bak` file to your desired backup location.
|
||||||
|
|
||||||
|
### PostgreSQL
|
||||||
|
|
||||||
|
You can back up the `config` folder and dump the PostgreSQL database without stopping the Jellyseerr application.
|
||||||
|
|
||||||
|
Install [postgresql-client](https://www.postgresql.org/download/) and run the following command to create a backup (just replace the placeholders):
|
||||||
|
|
||||||
|
:::info
|
||||||
|
Depending on how your PostgreSQL instance is configured, you may need to add these options to the command below.
|
||||||
|
|
||||||
|
-h, --host=HOSTNAME database server host or socket directory
|
||||||
|
|
||||||
|
-p, --port=PORT database server port number
|
||||||
|
:::
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pg_dump -U <database_user> -d <database_name> -f /tmp/jellyseerr_db.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
|
||||||
|
### SQLite
|
||||||
|
|
||||||
|
After restoring your `db/db.sqlite3` file and, optionally, the `settings.json` file, the `config` folder structure should look like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── cache <-- Optional
|
||||||
|
├── db
|
||||||
|
│ └── db.sqlite3
|
||||||
|
├── logs <-- Optional
|
||||||
|
└── settings.json <-- Optional (required if you want to avoid reconfiguring Jellyseerr)
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the files are restored, start the Jellyseerr application.
|
||||||
|
|
||||||
|
### PostgreSQL
|
||||||
|
|
||||||
|
Install the [PostgreSQL client](https://www.postgresql.org/download/) and restore the PostgreSQL database using the following command (replace the placeholders accordingly):
|
||||||
|
|
||||||
|
:::info
|
||||||
|
Depending on how your PostgreSQL instance is configured, you may need to add these options to the command below.
|
||||||
|
|
||||||
|
-h, --host=HOSTNAME database server host or socket directory
|
||||||
|
|
||||||
|
-p, --port=PORT database server port number
|
||||||
|
:::
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pg_restore -U <database_user> -d <database_name> /tmp/jellyseerr_db.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Optionally, restore the `settings.json` file. The `config` folder structure should look like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── cache <-- Optional
|
||||||
|
├── logs <-- Optional
|
||||||
|
└── settings.json <-- Optional (required if you want to avoid reconfiguring Jellyseerr)
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the database and files are restored, start the Jellyseerr application.
|
||||||
21
docs/using-jellyseerr/notifications/gotify.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
title: Gotify
|
||||||
|
description: Configure Gotify notifications.
|
||||||
|
sidebar_position: 5
|
||||||
|
---
|
||||||
|
|
||||||
|
# Gotify
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Server URL
|
||||||
|
|
||||||
|
Set this to the URL of your Gotify server.
|
||||||
|
|
||||||
|
### Application Token
|
||||||
|
|
||||||
|
Add an application to your Gotify server, and set this field to the generated application token.
|
||||||
|
|
||||||
|
:::info
|
||||||
|
Please refer to the [Gotify API documentation](https://gotify.net/docs) for more details on configuring these notifications.
|
||||||
|
:::
|
||||||
29
docs/using-jellyseerr/notifications/ntfy.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
title: ntfy.sh
|
||||||
|
description: Configure ntfy.sh notifications.
|
||||||
|
sidebar_position: 6
|
||||||
|
---
|
||||||
|
|
||||||
|
# ntfy.sh
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Server Root URL
|
||||||
|
|
||||||
|
Set this to the URL of your ntfy.sh server.
|
||||||
|
|
||||||
|
### Topic
|
||||||
|
|
||||||
|
Set this to the topic you want to send notifications to.
|
||||||
|
|
||||||
|
### Username + Password authentication (optional)
|
||||||
|
|
||||||
|
Set this to the username and password for your ntfy.sh server.
|
||||||
|
|
||||||
|
### Token authentication (optional)
|
||||||
|
|
||||||
|
Set this to the token for your ntfy.sh server.
|
||||||
|
|
||||||
|
:::info
|
||||||
|
Please refer to the [ntfy.sh API documentation](https://docs.ntfy.sh/) for more details on configuring these notifications.
|
||||||
|
:::
|
||||||
23
docs/using-jellyseerr/notifications/pushbullet.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
title: Pushbullet
|
||||||
|
description: Configure Pushbullet notifications.
|
||||||
|
sidebar_position: 7
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pushbullet
|
||||||
|
|
||||||
|
:::info
|
||||||
|
Users can optionally configure personal notifications in their user settings.
|
||||||
|
|
||||||
|
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Access Token
|
||||||
|
|
||||||
|
[Create an access token](https://www.pushbullet.com/#settings) and set it here to grant Jellyseerr access to the Pushbullet API.
|
||||||
|
|
||||||
|
### Channel Tag (optional)
|
||||||
|
|
||||||
|
Optionally, [create a channel](https://www.pushbullet.com/my-channel) to allow other users to follow the notification feed using the specified channel tag.
|
||||||
27
docs/using-jellyseerr/notifications/pushover.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
title: Pushover
|
||||||
|
description: Configure Pushover notifications.
|
||||||
|
sidebar_position: 8
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pushover
|
||||||
|
|
||||||
|
:::info
|
||||||
|
Users can optionally configure personal notifications in their user settings.
|
||||||
|
|
||||||
|
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Application/API Token
|
||||||
|
|
||||||
|
[Register an application](https://pushover.net/apps/build) and enter the API token in this field. (You can use one of the [official icons in our GitHub repository](https://github.com/fallenbagel/jellyseerr/tree/develop/public) when configuring the application.)
|
||||||
|
|
||||||
|
For more details on registering applications or the API token, please see the [Pushover API documentation](https://pushover.net/api#registration).
|
||||||
|
|
||||||
|
### User Key
|
||||||
|
|
||||||
|
Set this to the user key for your Pushover account. Alternatively, you can set this to a group key to deliver notifications to multiple users.
|
||||||
|
|
||||||
|
For more details, please see the [Pushover API documentation](https://pushover.net/api#identifiers).
|
||||||
17
docs/using-jellyseerr/notifications/slack.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
title: Slack
|
||||||
|
description: Configure Slack notifications.
|
||||||
|
sidebar_position: 9
|
||||||
|
---
|
||||||
|
|
||||||
|
# Slack
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Webhook URL
|
||||||
|
|
||||||
|
Simply [create a webhook](https://my.slack.com/services/new/incoming-webhook/) and enter the URL in this field.
|
||||||
|
|
||||||
|
:::info
|
||||||
|
Please refer to the [Slack API documentation](https://api.slack.com/messaging/webhooks) for more details on configuring these notifications.
|
||||||
|
:::
|
||||||
39
docs/using-jellyseerr/notifications/telegram.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
title: Telegram
|
||||||
|
description: Configure Telegram notifications.
|
||||||
|
sidebar_position: 10
|
||||||
|
---
|
||||||
|
|
||||||
|
# Telegram
|
||||||
|
|
||||||
|
:::info
|
||||||
|
Users can optionally configure personal notifications in their user settings.
|
||||||
|
|
||||||
|
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
:::info
|
||||||
|
In order to configure Telegram notifications, you first need to [create a bot](https://telegram.me/BotFather).
|
||||||
|
|
||||||
|
Bots **cannot** initiate conversations with users, so users must have your bot added to a conversation in order to receive notifications.
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Bot Username (optional)
|
||||||
|
|
||||||
|
If this value is configured, users will be able to click a link to start a chat with your bot and configure their own personal notifications.
|
||||||
|
|
||||||
|
The bot username should end with `_bot`, and the `@` prefix should be omitted.
|
||||||
|
|
||||||
|
### Bot Authentication Token
|
||||||
|
|
||||||
|
At the end of the bot creation process, [@BotFather](https://telegram.me/botfather) will provide an authentication token.
|
||||||
|
|
||||||
|
### Chat ID
|
||||||
|
|
||||||
|
To obtain your chat ID, simply create a new group chat, add [@get_id_bot](https://telegram.me/get_id_bot), and issue the `/my_id` command.
|
||||||
|
|
||||||
|
### Send Silently (optional)
|
||||||
|
|
||||||
|
Optionally, notifications can be sent silently. Silent notifications send messages without notification sounds.
|
||||||
138
docs/using-jellyseerr/notifications/webhook.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
---
|
||||||
|
title: Webhook
|
||||||
|
description: Configure webhook notifications.
|
||||||
|
sidebar_position: 4
|
||||||
|
---
|
||||||
|
|
||||||
|
# Webhook
|
||||||
|
|
||||||
|
The webhook notification agent enables you to send a custom JSON payload to any endpoint for specific notification events.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Webhook URL
|
||||||
|
|
||||||
|
The URL you would like to post notifications to. Your JSON will be sent as the body of the request.
|
||||||
|
|
||||||
|
### Authorization Header (optional)
|
||||||
|
|
||||||
|
:::info
|
||||||
|
This is typically not needed. Please refer to your webhook provider's documentation for details.
|
||||||
|
:::
|
||||||
|
|
||||||
|
This value will be sent as an `Authorization` HTTP header.
|
||||||
|
|
||||||
|
### JSON Payload
|
||||||
|
|
||||||
|
Customize the JSON payload to suit your needs. Jellyseerr provides several [template variables](#template-variables) for use in the payload, which will be replaced with the relevant data when the notifications are triggered.
|
||||||
|
|
||||||
|
## Template Variables
|
||||||
|
|
||||||
|
### General
|
||||||
|
|
||||||
|
| Variable | Value |
|
||||||
|
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `{{notification_type}}` | The type of notification (e.g. `MEDIA_PENDING` or `ISSUE_COMMENT`) |
|
||||||
|
| `{{event}}` | A friendly description of the notification event |
|
||||||
|
| `{{subject}}` | The notification subject (typically the media title) |
|
||||||
|
| `{{message}}` | The notification message body (the media overview/synopsis for request notifications; the issue description for issue notificatons) |
|
||||||
|
| `{{image}}` | The notification image (typically the media poster) |
|
||||||
|
|
||||||
|
### Notify User
|
||||||
|
|
||||||
|
These variables are for the target recipient of the notification.
|
||||||
|
|
||||||
|
| Variable | Value |
|
||||||
|
| ---------------------------------------- | ------------------------------------------------------------- |
|
||||||
|
| `{{notifyuser_username}}` | The target notification recipient's username |
|
||||||
|
| `{{notifyuser_email}}` | The target notification recipient's email address |
|
||||||
|
| `{{notifyuser_avatar}}` | The target notification recipient's avatar URL |
|
||||||
|
| `{{notifyuser_settings_discordId}}` | The target notification recipient's Discord ID (if set) |
|
||||||
|
| `{{notifyuser_settings_telegramChatId}}` | The target notification recipient's Telegram Chat ID (if set) |
|
||||||
|
|
||||||
|
:::info
|
||||||
|
The `notifyuser` variables are not defined for the following request notification types, as they are intended for application administrators rather than end users:
|
||||||
|
|
||||||
|
- Request Pending Approval
|
||||||
|
- Request Automatically Approved
|
||||||
|
- Request Processing Failed
|
||||||
|
|
||||||
|
On the other hand, the `notifyuser` variables _will_ be replaced with the requesting user's information for the below notification types:
|
||||||
|
|
||||||
|
- Request Approved
|
||||||
|
- Request Declined
|
||||||
|
- Request Available
|
||||||
|
|
||||||
|
If you would like to use the requesting user's information in your webhook, please instead include the relevant variables from the [Request](#request) section below.
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Special
|
||||||
|
|
||||||
|
The following variables must be used as a key in the JSON payload (e.g., `"{{extra}}": []`).
|
||||||
|
|
||||||
|
| Variable | Value |
|
||||||
|
| ------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `{{media}}` | The relevant media object |
|
||||||
|
| `{{request}}` | The relevant request object |
|
||||||
|
| `{{issue}}` | The relevant issue object |
|
||||||
|
| `{{comment}}` | The relevant issue comment object |
|
||||||
|
| `{{extra}}` | The "extra" array of additional data for certain notifications (e.g., season/episode numbers for series-related notifications) |
|
||||||
|
|
||||||
|
#### Media
|
||||||
|
|
||||||
|
The `{{media}}` will be `null` if there is no relevant media object for the notification.
|
||||||
|
|
||||||
|
These following special variables are only included in media-related notifications, such as requests.
|
||||||
|
|
||||||
|
| Variable | Value |
|
||||||
|
| -------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `{{media_type}}` | The media type (`movie` or `tv`) |
|
||||||
|
| `{{media_tmdbid}}` | The media's TMDB ID |
|
||||||
|
| `{{media_tvdbid}}` | The media's TheTVDB ID |
|
||||||
|
| `{{media_status}}` | The media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
|
||||||
|
| `{{media_status4k}}` | The media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
|
||||||
|
|
||||||
|
#### Request
|
||||||
|
|
||||||
|
The `{{request}}` will be `null` if there is no relevant media object for the notification.
|
||||||
|
|
||||||
|
The following special variables are only included in request-related notifications.
|
||||||
|
|
||||||
|
| Variable | Value |
|
||||||
|
| ----------------------------------------- | ----------------------------------------------- |
|
||||||
|
| `{{request_id}}` | The request ID |
|
||||||
|
| `{{requestedBy_username}}` | The requesting user's username |
|
||||||
|
| `{{requestedBy_email}}` | The requesting user's email address |
|
||||||
|
| `{{requestedBy_avatar}}` | The requesting user's avatar URL |
|
||||||
|
| `{{requestedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
|
||||||
|
| `{{requestedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
|
||||||
|
|
||||||
|
#### Issue
|
||||||
|
|
||||||
|
The `{{issue}}` will be `null` if there is no relevant media object for the notification.
|
||||||
|
|
||||||
|
The following special variables are only included in issue-related notifications.
|
||||||
|
|
||||||
|
| Variable | Value |
|
||||||
|
| ---------------------------------------- | ----------------------------------------------- |
|
||||||
|
| `{{issue_id}}` | The issue ID |
|
||||||
|
| `{{reportedBy_username}}` | The requesting user's username |
|
||||||
|
| `{{reportedBy_email}}` | The requesting user's email address |
|
||||||
|
| `{{reportedBy_avatar}}` | The requesting user's avatar URL |
|
||||||
|
| `{{reportedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
|
||||||
|
| `{{reportedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
|
||||||
|
|
||||||
|
#### Comment
|
||||||
|
|
||||||
|
The `{{comment}}` will be `null` if there is no relevant media object for the notification.
|
||||||
|
|
||||||
|
The following special variables are only included in issue comment-related notifications.
|
||||||
|
|
||||||
|
| Variable | Value |
|
||||||
|
| ----------------------------------------- | ----------------------------------------------- |
|
||||||
|
| `{{comment_message}}` | The comment message |
|
||||||
|
| `{{commentedBy_username}}` | The commenting user's username |
|
||||||
|
| `{{commentedBy_email}}` | The commenting user's email address |
|
||||||
|
| `{{commentedBy_avatar}}` | The commenting user's avatar URL |
|
||||||
|
| `{{commentedBy_settings_discordId}}` | The commenting user's Discord ID (if set) |
|
||||||
|
| `{{commentedBy_settings_telegramChatId}}` | The commenting user's Telegram Chat ID (if set) |
|
||||||
10
docs/using-jellyseerr/plex/_category_.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"label": "Plex Integration",
|
||||||
|
"position": 3,
|
||||||
|
"link": {
|
||||||
|
"type": "generated-index",
|
||||||
|
"title": "Plex Integration",
|
||||||
|
"description": "Learn about Jellyseerr's Plex integration features"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
36
docs/using-jellyseerr/plex/index.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
title: Overview
|
||||||
|
description: Learn about Jellyseerr's Plex integration features
|
||||||
|
sidebar_position: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
# Plex Features Overview
|
||||||
|
|
||||||
|
Jellyseerr provides integration features that connect with your Plex media server to automate media management tasks.
|
||||||
|
|
||||||
|
## Available Features
|
||||||
|
|
||||||
|
- [Watchlist Auto Request](./plex/watchlist-auto-request) - Automatically request media from your Plex Watchlist
|
||||||
|
- More features coming soon!
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
:::info Authentication Required
|
||||||
|
To use any Plex integration features, you must have logged into Jellyseerr at least once with your Plex account.
|
||||||
|
:::
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Plex account with access to the configured Plex server
|
||||||
|
- Jellyseerr configured with Plex as the media server
|
||||||
|
- User authentication via Plex login
|
||||||
|
- Appropriate user permissions for specific features
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Authenticate at least once using your Plex credentials
|
||||||
|
2. Verify you have the necessary permissions for desired features
|
||||||
|
3. Follow individual feature guides for setup instructions
|
||||||
|
|
||||||
|
:::note Server Configuration
|
||||||
|
Plex server configuration is handled by your administrator. If you cannot log in with your Plex account, contact your administrator to verify the server setup.
|
||||||
|
:::
|
||||||
95
docs/using-jellyseerr/plex/watchlist-auto-request.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
title: Watchlist Auto Request
|
||||||
|
description: Learn how to use the Plex Watchlist Auto Request feature
|
||||||
|
sidebar_position: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
# Watchlist Auto Request
|
||||||
|
|
||||||
|
The Plex Watchlist Auto Request feature allows Jellyseerr to automatically create requests for media items you add to your Plex Watchlist. Simply add content to your Plex Watchlist, and Jellyseerr will automatically request it for you.
|
||||||
|
|
||||||
|
:::info
|
||||||
|
This feature is only available for Plex users. Local users cannot use the Watchlist Auto Request feature.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- You must have logged into Jellyseerr at least once with your Plex account
|
||||||
|
- Your administrator must have granted you the necessary permissions
|
||||||
|
- Your Plex account must have access to the Plex server configured in Jellyseerr
|
||||||
|
|
||||||
|
## Permission System
|
||||||
|
|
||||||
|
The Watchlist Auto Request feature uses a two-tier permission system:
|
||||||
|
|
||||||
|
### Administrator Permissions (Required)
|
||||||
|
Your administrator must grant you these permissions in your user profile:
|
||||||
|
- **Auto-Request** (master permission)
|
||||||
|
- **Auto-Request Movies** (for movie auto-requests)
|
||||||
|
- **Auto-Request Series** (for TV series auto-requests)
|
||||||
|
|
||||||
|
### User Activation (Required)
|
||||||
|
You must enable the feature in your own profile settings:
|
||||||
|
- **Auto-Request Movies** toggle
|
||||||
|
- **Auto-Request Series** toggle
|
||||||
|
|
||||||
|
:::warning Two-Step Process
|
||||||
|
Both administrator permissions AND user activation are required. Having permissions doesn't automatically enable the feature - you must also activate it in your profile.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## How to Enable
|
||||||
|
|
||||||
|
### Step 1: Check Your Permissions
|
||||||
|
Contact your administrator to verify you have been granted:
|
||||||
|
- `Auto-Request` permission
|
||||||
|
- `Auto-Request Movies` and/or `Auto-Request Series` permissions
|
||||||
|
|
||||||
|
### Step 2: Activate the Feature
|
||||||
|
1. Go to your user profile settings
|
||||||
|
2. Navigate to the "General" section
|
||||||
|
3. Find the "Auto-Request" options
|
||||||
|
4. Enable the toggles for:
|
||||||
|
- **Auto-Request Movies** - to automatically request movies from your watchlist
|
||||||
|
- **Auto-Request Series** - to automatically request TV series from your watchlist
|
||||||
|
|
||||||
|
### Step 3: Start Using
|
||||||
|
- Add movies and TV shows to your Plex Watchlist
|
||||||
|
- Jellyseerr will automatically create requests for new items
|
||||||
|
- You'll receive notifications when items are auto-requested
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
Once properly configured, Jellyseerr will:
|
||||||
|
|
||||||
|
1. Periodically checks your Plex Watchlist for new items
|
||||||
|
2. Verify if the content already exists in your media libraries
|
||||||
|
3. Automatically submits requests for new items that aren't already available
|
||||||
|
4. Only requests content types you have permissions for
|
||||||
|
5. Notifiy you when auto-requests are created
|
||||||
|
|
||||||
|
:::info Content Limitations
|
||||||
|
Auto-request only works for standard quality content. 4K content must be requested manually if you have 4K permissions.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## For Administrators
|
||||||
|
|
||||||
|
### Granting Permissions
|
||||||
|
1. Navigate to **Users** > **[Select User]** > **Permissions**
|
||||||
|
2. Enable the required permissions:
|
||||||
|
- **Auto-Request** (master toggle)
|
||||||
|
- **Auto-Request Movies** (for movie auto-requests)
|
||||||
|
- **Auto-Request Series** (for TV series auto-requests)
|
||||||
|
3. Optionally enable **Auto-Approve** permissions for automatic approval
|
||||||
|
|
||||||
|
### Default Permissions
|
||||||
|
- Go to **Settings** > **Users** > **Default Permissions**
|
||||||
|
- Configure auto-request permissions for new users
|
||||||
|
- This sets the default permissions but users still need to activate the feature individually
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- Local users cannot use this feature
|
||||||
|
- 4K content requires manual requests
|
||||||
|
- Users must have logged into Jellyseerr with their Plex account
|
||||||
|
- Respects user request limits and quotas
|
||||||
|
- Won't request content already in your libraries
|
||||||
16
docs/using-jellyseerr/settings/dns-caching.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
title: DNS Caching
|
||||||
|
description: Configure DNS caching settings.
|
||||||
|
sidebar_position: 7
|
||||||
|
---
|
||||||
|
|
||||||
|
# DNS Caching
|
||||||
|
|
||||||
|
Jellyseerr uses DNS caching to improve performance and reduce the number of DNS lookups required for external API calls. This can help speed up response times and reduce load on DNS servers, when something like a Pi-hole is used as a DNS resolver.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
You can enable the DNS caching settings in the Network tab of the Jellyseerr settings. The default values follow the standard DNS caching behavior.
|
||||||
|
|
||||||
|
- **Force Minimum TTL**: Set a minimum time-to-live (TTL) in seconds for DNS cache entries. This ensures that frequently accessed DNS records are cached for a longer period, reducing the need for repeated lookups. Default is 0.
|
||||||
|
- **Force Maximum TTL**: Set a maximum time-to-live (TTL) in seconds for DNS cache entries. This prevents infrequently accessed DNS records from being cached indefinitely, allowing for more up-to-date information to be retrieved. Default is -1 (unlimited).
|
||||||
@@ -62,6 +62,14 @@ Set the default display language for Jellyseerr. Users can override this setting
|
|||||||
|
|
||||||
These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. The Streaming Region filters the available streaming providers on the media page. Users can override these global settings by configuring these same options in their user settings.
|
These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. The Streaming Region filters the available streaming providers on the media page. Users can override these global settings by configuring these same options in their user settings.
|
||||||
|
|
||||||
|
## Blacklist Content with Tags and Limit Content Blacklisted per Tag
|
||||||
|
|
||||||
|
These settings blacklist any TV shows or movies that have one of the entered tags. The "Process Blacklisted Tags" job adds entries to the blacklist based on the configured blacklisted tags. If a blacklisted tag is removed, any media blacklisted under that tag will be removed from the blacklist when the "Process Blacklisted Tags" job runs.
|
||||||
|
|
||||||
|
The limit setting determines how many pages per tag the job will process, with each page containing 20 entries. The job cycles through all 16 available discovery sort options, querying the defined number of pages to blacklist media that is most likely to appear at the top of each sort. Higher limits will create a more accurate blacklist, but will require more storage.
|
||||||
|
|
||||||
|
Blacklisted tags are disabled until at least one tag is entered. These settings cannot be overridden in user settings.
|
||||||
|
|
||||||
## Hide Available Media
|
## Hide Available Media
|
||||||
|
|
||||||
When enabled, media which is already available will not appear on the "Discover" home page, or in the "Recommended" or "Similar" categories or other links on media detail pages.
|
When enabled, media which is already available will not appear on the "Discover" home page, or in the "Recommended" or "Similar" categories or other links on media detail pages.
|
||||||
@@ -70,6 +78,12 @@ Available media will still appear in search results, however, so it is possible
|
|||||||
|
|
||||||
This setting is **disabled** by default.
|
This setting is **disabled** by default.
|
||||||
|
|
||||||
|
## Hide Blacklisted Items
|
||||||
|
|
||||||
|
When enabled, media that has been blacklisted will not appear on the "Discover" home page, for all administrators. This can be useful to hide content that you don't want to see, such as content with specific tags or content that has been manually blacklisted when you have the "Manage Blacklist" permission.
|
||||||
|
|
||||||
|
This setting is **disabled** by default.
|
||||||
|
|
||||||
## Allow Partial Series Requests
|
## Allow Partial Series Requests
|
||||||
|
|
||||||
When enabled, users will be able to submit requests for specific seasons of TV series. If disabled, users will only be able to submit requests for all unavailable seasons.
|
When enabled, users will be able to submit requests for specific seasons of TV series. If disabled, users will only be able to submit requests for all unavailable seasons.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: Jobs & Cache
|
title: Jobs & Cache
|
||||||
description: Configure jobs and cache settings.
|
description: Configure jobs and cache settings.
|
||||||
|
sidebar_position: 6
|
||||||
---
|
---
|
||||||
|
|
||||||
# Jobs & Cache
|
# Jobs & Cache
|
||||||
|
|||||||
BIN
docs/using-jellyseerr/settings/users/assets/oidc_keycloak_1.png
Normal file
|
After Width: | Height: | Size: 375 KiB |
BIN
docs/using-jellyseerr/settings/users/assets/oidc_keycloak_2.png
Normal file
|
After Width: | Height: | Size: 296 KiB |
BIN
docs/using-jellyseerr/settings/users/assets/oidc_keycloak_3.png
Normal file
|
After Width: | Height: | Size: 306 KiB |
BIN
docs/using-jellyseerr/settings/users/assets/oidc_keycloak_4.png
Normal file
|
After Width: | Height: | Size: 306 KiB |
BIN
docs/using-jellyseerr/settings/users/assets/oidc_keycloak_5.png
Normal file
|
After Width: | Height: | Size: 285 KiB |
@@ -14,6 +14,22 @@ When disabled, your mediaserver OAuth becomes the only sign-in option, and any "
|
|||||||
|
|
||||||
This setting is **enabled** by default.
|
This setting is **enabled** by default.
|
||||||
|
|
||||||
|
## Enable Jellyfin/Emby/Plex Sign-In
|
||||||
|
|
||||||
|
When enabled, users will be able to sign in to Jellyseerr using their Jellyfin/Emby/Plex credentials, provided they have linked their media server accounts.
|
||||||
|
|
||||||
|
When disabled, users will only be able to sign in using their email address. Users without a password set will not be able to sign in to Jellyseerr.
|
||||||
|
|
||||||
|
This setting is **enabled** by default.
|
||||||
|
|
||||||
|
## Enable OpenID Connect Sign-In
|
||||||
|
|
||||||
|
When enabled, users will be able to sign in to Jellyseerr using their OpenID Connect credentials, provided they have linked their OpenID Connect accounts. Once enabled, the [OpenID Connect settings](./oidc.md) can be accessed using the settings cog to the right of this option, and OpenID Connect providers can be configured.
|
||||||
|
|
||||||
|
When disabled, users will only be able to sign in using their Jellyseerr username or email address. Users without a password set will not be able to sign in to Jellyseerr.
|
||||||
|
|
||||||
|
This setting is **disabled** by default.
|
||||||
|
|
||||||
## Enable New Jellyfin/Emby/Plex Sign-In
|
## Enable New Jellyfin/Emby/Plex Sign-In
|
||||||
|
|
||||||
When enabled, users with access to your media server will be able to sign in to Jellyseerr even if they have not yet been imported. Users will be automatically assigned the permissions configured in the [Default Permissions](#default-permissions) setting upon first sign-in.
|
When enabled, users with access to your media server will be able to sign in to Jellyseerr even if they have not yet been imported. Users will be automatically assigned the permissions configured in the [Default Permissions](#default-permissions) setting upon first sign-in.
|
||||||
82
docs/using-jellyseerr/settings/users/oidc.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
title: OpenID Connect
|
||||||
|
description: Configure OpenID Connect settings.
|
||||||
|
sidebar_position: 2.5
|
||||||
|
---
|
||||||
|
|
||||||
|
# OpenID Connect
|
||||||
|
|
||||||
|
Jellyseerr supports OpenID Connect (OIDC) for authentication and authorization. To begin setting up OpenID Connect, follow these steps:
|
||||||
|
|
||||||
|
1. First, enable OpenID Connect [on the User settings page](./index.md#enable-openid-connect-sign-in).
|
||||||
|
2. Once enabled, access OpenID Connect settings using the cog icon to the right.
|
||||||
|
3. Add a new provider by clicking the "Add Provider" button.
|
||||||
|
4. Configure the provider with the options described below.
|
||||||
|
5. Link your OpenID Connect account to your Jellyseerr account using the "Link Account" button on the Linked Accounts page in your user's settings.
|
||||||
|
6. Finally, you should be able to log in using your OpenID Connect account.
|
||||||
|
|
||||||
|
## Configuration Options
|
||||||
|
|
||||||
|
### Provider Name
|
||||||
|
|
||||||
|
Name of the provider which appears on the login screen.
|
||||||
|
|
||||||
|
Configuring this setting will automatically determine the [provider slug](#provider-slug), unless it is manually specified.
|
||||||
|
|
||||||
|
### Logo
|
||||||
|
|
||||||
|
The logo to display for the provider. Should be a URL or base64 encoded image.
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
|
||||||
|
The search icon at the right of the logo field opens the [selfh.st/icons](https://selfh.st/icons) database. These icons include popular self-hosted OpenID Connect providers.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Issuer URL
|
||||||
|
The base URL of the identity provider's OpenID Connect endpoint
|
||||||
|
|
||||||
|
### Client ID
|
||||||
|
|
||||||
|
The Client ID assigned to Jellyseerr
|
||||||
|
|
||||||
|
### Client Secret
|
||||||
|
|
||||||
|
The Client Secret assigned to Jellyseerr
|
||||||
|
|
||||||
|
### Provider Slug
|
||||||
|
|
||||||
|
Unique identifier for the provider
|
||||||
|
|
||||||
|
### Scopes
|
||||||
|
|
||||||
|
Space-separated list of scopes to request from the provider
|
||||||
|
|
||||||
|
### Required Claims
|
||||||
|
|
||||||
|
Space-separated list of boolean claims that are required to log in
|
||||||
|
|
||||||
|
### Allow New Users
|
||||||
|
|
||||||
|
Create accounts for new users logging in with this provider
|
||||||
|
|
||||||
|
## Provider Guides
|
||||||
|
|
||||||
|
### Keycloak
|
||||||
|
|
||||||
|
To set up Keycloak, follow these steps:
|
||||||
|
|
||||||
|
1. First, create a new client in Keycloak.
|
||||||
|

|
||||||
|
|
||||||
|
1. Set the client ID to `jellyseerr`, and set the name to "Jellyseerr" (or whatever you prefer).
|
||||||
|

|
||||||
|
|
||||||
|
1. Next, be sure to enable "Client authentication" in the capabilities section. The remaining defaults should be fine.
|
||||||
|

|
||||||
|
|
||||||
|
1. Finally, set the root url to your Jellyseerr instance's URL, and add the login page as a valid redirect URL.
|
||||||
|

|
||||||
|
|
||||||
|
1. With all that set up, you should be able to configure Jellyseerr to use Keycloak for authentication. Be sure to copy the client secret from the credentials page, as shown above. The issuer URL can be obtained from the "Realm Settings" page, by copying the link titled "OpenID Endpoint Configuration".
|
||||||
|

|
||||||
@@ -11,7 +11,7 @@ const config: Config = {
|
|||||||
baseUrl: '/',
|
baseUrl: '/',
|
||||||
trailingSlash: false,
|
trailingSlash: false,
|
||||||
|
|
||||||
organizationName: 'Fallenbagel',
|
organizationName: 'fallenbagel',
|
||||||
projectName: 'Jellyseerr',
|
projectName: 'Jellyseerr',
|
||||||
deploymentBranch: 'gh-pages',
|
deploymentBranch: 'gh-pages',
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ const config: Config = {
|
|||||||
routeBasePath: '/',
|
routeBasePath: '/',
|
||||||
path: '../docs',
|
path: '../docs',
|
||||||
editUrl:
|
editUrl:
|
||||||
'https://github.com/Fallenbagel/jellyseerr/edit/develop/docs/',
|
'https://github.com/fallenbagel/jellyseerr/edit/develop/docs/',
|
||||||
},
|
},
|
||||||
blog: false,
|
blog: false,
|
||||||
pages: false,
|
pages: false,
|
||||||
@@ -70,7 +70,7 @@ const config: Config = {
|
|||||||
},
|
},
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
href: 'https://github.com/Fallenbagel/jellyseerr',
|
href: 'https://github.com/fallenbagel/jellyseerr',
|
||||||
label: 'GitHub',
|
label: 'GitHub',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -47,6 +47,6 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0"
|
"node": ">=22.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,25 +26,37 @@ export const JellyseerrVersion = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const NixpkgVersion = () => {
|
export const NixpkgVersion = () => {
|
||||||
const [version, setVersion] = useState(null);
|
const [versions, setVersions] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchVersion = async () => {
|
const fetchVersion = async () => {
|
||||||
try {
|
try {
|
||||||
const url =
|
const unstableUrl =
|
||||||
'https://raw.githubusercontent.com/NixOS/nixpkgs/nixos-unstable/pkgs/servers/jellyseerr/default.nix';
|
'https://raw.githubusercontent.com/NixOS/nixpkgs/refs/heads/nixos-unstable/pkgs/by-name/je/jellyseerr/package.nix';
|
||||||
const response = await fetch(url);
|
const stableUrl =
|
||||||
const data = await response.text();
|
'https://raw.githubusercontent.com/NixOS/nixpkgs/refs/heads/nixos-24.11/pkgs/servers/jellyseerr/default.nix';
|
||||||
|
|
||||||
|
const [unstableResponse, stableResponse] = await Promise.all([
|
||||||
|
fetch(unstableUrl),
|
||||||
|
fetch(stableUrl),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const unstableData = await unstableResponse.text();
|
||||||
|
const stableData = await stableResponse.text();
|
||||||
|
|
||||||
const versionRegex = /version\s*=\s*"([^"]+)"/;
|
const versionRegex = /version\s*=\s*"([^"]+)"/;
|
||||||
const match = data.match(versionRegex);
|
|
||||||
if (match && match[1]) {
|
const unstableMatch = unstableData.match(versionRegex);
|
||||||
setVersion(match[1]);
|
const stableMatch = stableData.match(versionRegex);
|
||||||
} else {
|
|
||||||
setError('0.0.0');
|
const unstableVersion =
|
||||||
}
|
unstableMatch && unstableMatch[1] ? unstableMatch[1] : '0.0.0';
|
||||||
|
const stableVersion =
|
||||||
|
stableMatch && stableMatch[1] ? stableMatch[1] : '0.0.0';
|
||||||
|
|
||||||
|
setVersions({ unstable: unstableVersion, stable: stableVersion });
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -63,5 +75,5 @@ export const NixpkgVersion = () => {
|
|||||||
return { error };
|
return { error };
|
||||||
}
|
}
|
||||||
|
|
||||||
return version;
|
return versions;
|
||||||
};
|
};
|
||||||
|
|||||||
2
next-env.d.ts
vendored
@@ -2,4 +2,4 @@
|
|||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
env: {
|
env: {
|
||||||
commitTag: process.env.COMMIT_TAG || 'local',
|
commitTag: process.env.COMMIT_TAG || 'local',
|
||||||
forceIpv4First: process.env.FORCE_IPV4_FIRST === 'true' ? 'true' : 'false',
|
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{ hostname: 'gravatar.com' },
|
{ hostname: 'gravatar.com' },
|
||||||
{ hostname: 'image.tmdb.org' },
|
{ hostname: 'image.tmdb.org' },
|
||||||
{ hostname: 'artworks.thetvdb.com' },
|
{ hostname: 'artworks.thetvdb.com' },
|
||||||
|
{ hostname: 'plex.tv' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
webpack(config) {
|
webpack(config) {
|
||||||
|
|||||||
120
package.json
@@ -5,7 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"postinstall": "node postinstall-win.js",
|
"postinstall": "node postinstall-win.js",
|
||||||
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
|
"dev": "nodemon -e ts --watch server --watch jellyseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
|
||||||
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json",
|
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json",
|
||||||
"build:next": "next build",
|
"build:next": "next build",
|
||||||
"build": "pnpm build:next && pnpm build:server",
|
"build": "pnpm build:next && pnpm build:server",
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dr.pogodin/csurf": "^1.14.1",
|
||||||
"@formatjs/intl-displaynames": "6.2.6",
|
"@formatjs/intl-displaynames": "6.2.6",
|
||||||
"@formatjs/intl-locale": "3.1.1",
|
"@formatjs/intl-locale": "3.1.1",
|
||||||
"@formatjs/intl-pluralrules": "5.1.10",
|
"@formatjs/intl-pluralrules": "5.1.10",
|
||||||
@@ -42,36 +43,43 @@
|
|||||||
"@supercharge/request-ip": "1.2.0",
|
"@supercharge/request-ip": "1.2.0",
|
||||||
"@svgr/webpack": "6.5.1",
|
"@svgr/webpack": "6.5.1",
|
||||||
"@tanem/react-nprogress": "5.0.30",
|
"@tanem/react-nprogress": "5.0.30",
|
||||||
|
"@types/ua-parser-js": "^0.7.36",
|
||||||
|
"@types/wink-jaro-distance": "^2.0.2",
|
||||||
"ace-builds": "1.15.2",
|
"ace-builds": "1.15.2",
|
||||||
|
"axios": "1.10.0",
|
||||||
|
"axios-rate-limit": "1.3.0",
|
||||||
"bcrypt": "5.1.0",
|
"bcrypt": "5.1.0",
|
||||||
"bowser": "2.11.0",
|
"bowser": "2.11.0",
|
||||||
"connect-typeorm": "1.1.4",
|
"connect-typeorm": "1.1.4",
|
||||||
"cookie-parser": "1.4.6",
|
"cookie-parser": "1.4.7",
|
||||||
"copy-to-clipboard": "3.3.3",
|
"copy-to-clipboard": "3.3.3",
|
||||||
"country-flag-icons": "1.5.5",
|
"country-flag-icons": "1.5.5",
|
||||||
"cronstrue": "2.23.0",
|
"cronstrue": "2.23.0",
|
||||||
"csurf": "1.11.0",
|
|
||||||
"date-fns": "2.29.3",
|
"date-fns": "2.29.3",
|
||||||
"dayjs": "1.11.7",
|
"dayjs": "1.11.7",
|
||||||
"email-templates": "9.0.0",
|
"dns-caching": "^0.2.5",
|
||||||
|
"email-templates": "12.0.1",
|
||||||
"email-validator": "2.0.4",
|
"email-validator": "2.0.4",
|
||||||
"express": "4.18.2",
|
"express": "4.21.2",
|
||||||
"express-openapi-validator": "4.13.8",
|
"express-openapi-validator": "4.13.8",
|
||||||
"express-rate-limit": "6.7.0",
|
"express-rate-limit": "6.7.0",
|
||||||
"express-session": "1.17.3",
|
"express-session": "1.17.3",
|
||||||
"formik": "^2.4.6",
|
"formik": "^2.4.6",
|
||||||
"gravatar-url": "3.1.0",
|
"gravatar-url": "3.1.0",
|
||||||
|
"http-proxy-agent": "^7.0.2",
|
||||||
|
"https-proxy-agent": "^7.0.6",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"mime": "3",
|
"mime": "3",
|
||||||
"next": "^14.2.4",
|
"next": "^14.2.25",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"node-gyp": "9.3.1",
|
"node-gyp": "9.3.1",
|
||||||
"node-schedule": "2.1.1",
|
"node-schedule": "2.1.1",
|
||||||
"nodemailer": "6.9.1",
|
"nodemailer": "6.10.0",
|
||||||
"openpgp": "5.7.0",
|
"openpgp": "5.11.2",
|
||||||
"pg": "8.11.0",
|
"pg": "8.11.0",
|
||||||
"plex-api": "5.3.2",
|
"plex-api": "5.3.2",
|
||||||
"pug": "3.0.2",
|
"pug": "3.0.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-ace": "10.1.0",
|
"react-ace": "10.1.0",
|
||||||
"react-animate-height": "2.1.2",
|
"react-animate-height": "2.1.2",
|
||||||
@@ -85,35 +93,38 @@
|
|||||||
"react-spring": "9.7.1",
|
"react-spring": "9.7.1",
|
||||||
"react-tailwindcss-datepicker-sct": "1.3.4",
|
"react-tailwindcss-datepicker-sct": "1.3.4",
|
||||||
"react-toast-notifications": "2.5.1",
|
"react-toast-notifications": "2.5.1",
|
||||||
|
"react-transition-group": "^4.4.5",
|
||||||
"react-truncate-markup": "5.1.2",
|
"react-truncate-markup": "5.1.2",
|
||||||
"react-use-clipboard": "1.0.9",
|
"react-use-clipboard": "1.0.9",
|
||||||
"reflect-metadata": "0.1.13",
|
"reflect-metadata": "0.1.13",
|
||||||
"secure-random-password": "0.2.3",
|
"secure-random-password": "0.2.3",
|
||||||
"semver": "7.3.8",
|
"semver": "7.7.1",
|
||||||
"sharp": "^0.33.4",
|
"sharp": "^0.33.4",
|
||||||
"sqlite3": "5.1.4",
|
"sqlite3": "5.1.7",
|
||||||
"swagger-ui-express": "4.6.2",
|
"swagger-ui-express": "4.6.2",
|
||||||
"swr": "2.2.5",
|
"swr": "2.2.5",
|
||||||
"typeorm": "0.3.11",
|
"tailwind-merge": "^2.6.0",
|
||||||
"undici": "^6.20.1",
|
"typeorm": "0.3.12",
|
||||||
|
"ua-parser-js": "^1.0.35",
|
||||||
|
"undici": "^7.3.0",
|
||||||
"web-push": "3.5.0",
|
"web-push": "3.5.0",
|
||||||
|
"wink-jaro-distance": "^2.0.0",
|
||||||
"winston": "3.8.2",
|
"winston": "3.8.2",
|
||||||
"winston-daily-rotate-file": "4.7.1",
|
"winston-daily-rotate-file": "4.7.1",
|
||||||
"xml2js": "0.4.23",
|
"xml2js": "0.4.23",
|
||||||
"yamljs": "0.3.0",
|
"yamljs": "0.3.0",
|
||||||
"yup": "0.32.11",
|
"yup": "0.32.11",
|
||||||
"zod": "3.20.6"
|
"zod": "3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@codedependant/semantic-release-docker": "^5.1.0",
|
||||||
"@commitlint/cli": "17.4.4",
|
"@commitlint/cli": "17.4.4",
|
||||||
"@commitlint/config-conventional": "17.4.4",
|
"@commitlint/config-conventional": "17.4.4",
|
||||||
"@semantic-release/changelog": "6.0.2",
|
"@semantic-release/changelog": "6.0.3",
|
||||||
"@semantic-release/commit-analyzer": "9.0.2",
|
|
||||||
"@semantic-release/exec": "6.0.3",
|
|
||||||
"@semantic-release/git": "10.0.1",
|
"@semantic-release/git": "10.0.1",
|
||||||
"@tailwindcss/aspect-ratio": "0.4.2",
|
"@tailwindcss/aspect-ratio": "0.4.2",
|
||||||
"@tailwindcss/forms": "0.5.3",
|
"@tailwindcss/forms": "0.5.10",
|
||||||
"@tailwindcss/typography": "0.5.9",
|
"@tailwindcss/typography": "0.5.16",
|
||||||
"@types/bcrypt": "5.0.0",
|
"@types/bcrypt": "5.0.0",
|
||||||
"@types/cookie-parser": "1.4.3",
|
"@types/cookie-parser": "1.4.3",
|
||||||
"@types/country-flag-icons": "1.2.0",
|
"@types/country-flag-icons": "1.2.0",
|
||||||
@@ -123,7 +134,7 @@
|
|||||||
"@types/express-session": "1.17.6",
|
"@types/express-session": "1.17.6",
|
||||||
"@types/lodash": "4.14.191",
|
"@types/lodash": "4.14.191",
|
||||||
"@types/mime": "3",
|
"@types/mime": "3",
|
||||||
"@types/node": "20.14.8",
|
"@types/node": "22.10.5",
|
||||||
"@types/node-schedule": "2.1.0",
|
"@types/node-schedule": "2.1.0",
|
||||||
"@types/nodemailer": "6.4.7",
|
"@types/nodemailer": "6.4.7",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
@@ -142,7 +153,7 @@
|
|||||||
"commitizen": "4.3.0",
|
"commitizen": "4.3.0",
|
||||||
"copyfiles": "2.4.1",
|
"copyfiles": "2.4.1",
|
||||||
"cy-mobile-commands": "0.3.0",
|
"cy-mobile-commands": "0.3.0",
|
||||||
"cypress": "12.7.0",
|
"cypress": "14.1.0",
|
||||||
"cz-conventional-changelog": "3.3.0",
|
"cz-conventional-changelog": "3.3.0",
|
||||||
"eslint": "8.35.0",
|
"eslint": "8.35.0",
|
||||||
"eslint-config-next": "^14.2.4",
|
"eslint-config-next": "^14.2.4",
|
||||||
@@ -155,13 +166,12 @@
|
|||||||
"eslint-plugin-react-hooks": "4.6.0",
|
"eslint-plugin-react-hooks": "4.6.0",
|
||||||
"husky": "8.0.3",
|
"husky": "8.0.3",
|
||||||
"lint-staged": "13.1.2",
|
"lint-staged": "13.1.2",
|
||||||
"nodemon": "2.0.20",
|
"nodemon": "3.1.9",
|
||||||
"postcss": "8.4.21",
|
"postcss": "8.4.31",
|
||||||
"prettier": "2.8.4",
|
"prettier": "2.8.4",
|
||||||
"prettier-plugin-organize-imports": "3.2.2",
|
"prettier-plugin-organize-imports": "3.2.2",
|
||||||
"prettier-plugin-tailwindcss": "0.2.3",
|
"prettier-plugin-tailwindcss": "0.2.3",
|
||||||
"semantic-release": "19.0.5",
|
"semantic-release": "24.2.7",
|
||||||
"semantic-release-docker-buildx": "1.0.1",
|
|
||||||
"tailwindcss": "3.2.7",
|
"tailwindcss": "3.2.7",
|
||||||
"ts-node": "10.9.1",
|
"ts-node": "10.9.1",
|
||||||
"tsc-alias": "1.8.2",
|
"tsc-alias": "1.8.2",
|
||||||
@@ -169,7 +179,7 @@
|
|||||||
"typescript": "4.9.5"
|
"typescript": "4.9.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.0.0",
|
"node": "^22.0.0",
|
||||||
"pnpm": "^9.0.0"
|
"pnpm": "^9.0.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
@@ -216,7 +226,49 @@
|
|||||||
"message": "chore(release): ${nextRelease.version}"
|
"message": "chore(release): ${nextRelease.version}"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"semantic-release-docker-buildx",
|
[
|
||||||
|
"@codedependant/semantic-release-docker",
|
||||||
|
{
|
||||||
|
"dockerArgs": {
|
||||||
|
"COMMIT_TAG": "$GIT_SHA"
|
||||||
|
},
|
||||||
|
"dockerLogin": false,
|
||||||
|
"dockerProject": "fallenbagel",
|
||||||
|
"dockerImage": "jellyseerr",
|
||||||
|
"dockerTags": [
|
||||||
|
"latest",
|
||||||
|
"{{major}}",
|
||||||
|
"{{major}}.{{minor}}",
|
||||||
|
"{{major}}.{{minor}}.{{patch}}"
|
||||||
|
],
|
||||||
|
"dockerPlatform": [
|
||||||
|
"linux/amd64",
|
||||||
|
"linux/arm64"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@codedependant/semantic-release-docker",
|
||||||
|
{
|
||||||
|
"dockerArgs": {
|
||||||
|
"COMMIT_TAG": "$GIT_SHA"
|
||||||
|
},
|
||||||
|
"dockerLogin": false,
|
||||||
|
"dockerRegistry": "ghcr.io",
|
||||||
|
"dockerProject": "fallenbagel",
|
||||||
|
"dockerImage": "jellyseerr",
|
||||||
|
"dockerTags": [
|
||||||
|
"latest",
|
||||||
|
"{{major}}",
|
||||||
|
"{{major}}.{{minor}}",
|
||||||
|
"{{major}}.{{minor}}.{{patch}}"
|
||||||
|
],
|
||||||
|
"dockerPlatform": [
|
||||||
|
"linux/amd64",
|
||||||
|
"linux/arm64"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"@semantic-release/github",
|
"@semantic-release/github",
|
||||||
{
|
{
|
||||||
@@ -229,19 +281,7 @@
|
|||||||
],
|
],
|
||||||
"npmPublish": false,
|
"npmPublish": false,
|
||||||
"publish": [
|
"publish": [
|
||||||
{
|
"@codedependant/semantic-release-docker",
|
||||||
"path": "semantic-release-docker-buildx",
|
|
||||||
"buildArgs": {
|
|
||||||
"COMMIT_TAG": "$GIT_SHA"
|
|
||||||
},
|
|
||||||
"imageNames": [
|
|
||||||
"fallenbagel/jellyseerr"
|
|
||||||
],
|
|
||||||
"platforms": [
|
|
||||||
"linux/amd64",
|
|
||||||
"linux/arm64"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"@semantic-release/github"
|
"@semantic-release/github"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
5465
pnpm-lock.yaml
generated
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
57
public/images/openid.svg
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
version="1.0"
|
||||||
|
width="120"
|
||||||
|
height="120"
|
||||||
|
id="svg2593"
|
||||||
|
xml:space="preserve"
|
||||||
|
sodipodi:docname="OpenID_logo_2.svg"
|
||||||
|
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#505050"
|
||||||
|
bordercolor="#eeeeee"
|
||||||
|
borderopacity="1"
|
||||||
|
inkscape:showpageshadow="0"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#505050"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="5.3731101"
|
||||||
|
inkscape:cx="69.60587"
|
||||||
|
inkscape:cy="65.883631"
|
||||||
|
inkscape:window-width="1512"
|
||||||
|
inkscape:window-height="945"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="37"
|
||||||
|
inkscape:window-maximized="0"
|
||||||
|
inkscape:current-layer="svg2593" /><defs
|
||||||
|
id="defs2596"><clipPath
|
||||||
|
id="clipPath2616"><path
|
||||||
|
d="M 0,14400 H 14400 V 0 H 0 Z"
|
||||||
|
id="path2618" /></clipPath></defs><g
|
||||||
|
transform="matrix(1.25,0,0,-1.25,-8601.6804,9121.1624)"
|
||||||
|
id="g2602"><g
|
||||||
|
transform="matrix(0.375,0,0,0.375,4301.4506,4557.5812)"
|
||||||
|
id="g2734"><g
|
||||||
|
id="g2726"><g
|
||||||
|
transform="translate(6998.0969,7259.1135)"
|
||||||
|
id="g2604"><path
|
||||||
|
d="M 0,0 V -159.939 -180 l 32,15.061 V 15.633 Z"
|
||||||
|
id="path2606"
|
||||||
|
style="fill:#f8931e;fill-opacity:1;fill-rule:nonzero;stroke:none" /></g><g
|
||||||
|
transform="translate(7108.9192,7206.3137)"
|
||||||
|
id="g2608"><path
|
||||||
|
d="M 0,0 4.417,-45.864 -57.466,-32.4"
|
||||||
|
id="path2610"
|
||||||
|
style="fill:#b3b3b3;fill-opacity:1;fill-rule:nonzero;stroke:none" /></g><g
|
||||||
|
transform="translate(6934.0969,7147.6213)"
|
||||||
|
id="g2620"><path
|
||||||
|
d="M 0,0 C 0,22.674 24.707,41.769 58.383,47.598 V 67.923 C 6.873,61.697 -32,33.656 -32,0 -32,-34.869 9.725,-63.709 64,-68.508 v 20.061 C 27.484,-43.869 0,-23.919 0,0 M 101.617,67.915 V 47.598 c 13.399,-2.319 25.385,-6.727 34.951,-12.64 l 22.627,13.984 c -15.42,9.531 -35.322,16.283 -57.578,18.973"
|
||||||
|
id="path2622"
|
||||||
|
style="fill:#b3b3b3;fill-opacity:1;fill-rule:nonzero;stroke:none" /></g></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
21
public/sw.js
@@ -3,7 +3,7 @@
|
|||||||
// previously cached resources to be updated from the network.
|
// previously cached resources to be updated from the network.
|
||||||
// This variable is intentionally declared and unused.
|
// This variable is intentionally declared and unused.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const OFFLINE_VERSION = 3;
|
const OFFLINE_VERSION = 4;
|
||||||
const CACHE_NAME = 'offline';
|
const CACHE_NAME = 'offline';
|
||||||
// Customize this with a different URL if needed.
|
// Customize this with a different URL if needed.
|
||||||
const OFFLINE_URL = '/offline.html';
|
const OFFLINE_URL = '/offline.html';
|
||||||
@@ -107,6 +107,25 @@ self.addEventListener('push', (event) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set the badge with the amount of pending requests
|
||||||
|
// Only update the badge if the payload confirms they are the admin
|
||||||
|
if (
|
||||||
|
(payload.notificationType === 'MEDIA_APPROVED' ||
|
||||||
|
payload.notificationType === 'MEDIA_DECLINED') &&
|
||||||
|
payload.isAdmin
|
||||||
|
) {
|
||||||
|
if ('setAppBadge' in navigator) {
|
||||||
|
navigator.setAppBadge(payload.pendingRequestsCount);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.notificationType === 'MEDIA_PENDING') {
|
||||||
|
if ('setAppBadge' in navigator) {
|
||||||
|
navigator.setAppBadge(payload.pendingRequestsCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
event.waitUntil(self.registration.showNotification(payload.subject, options));
|
event.waitUntil(self.registration.showNotification(payload.subject, options));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import fs, { promises as fsp } from 'node:fs';
|
import axios from 'axios';
|
||||||
import path from 'node:path';
|
import fs, { promises as fsp } from 'fs';
|
||||||
import { Readable } from 'node:stream';
|
import path from 'path';
|
||||||
import type { ReadableStream } from 'node:stream/web';
|
|
||||||
import xml2js from 'xml2js';
|
import xml2js from 'xml2js';
|
||||||
|
|
||||||
const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds
|
const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds
|
||||||
@@ -162,18 +161,14 @@ class AnimeListMapping {
|
|||||||
label: 'Anime-List Sync',
|
label: 'Anime-List Sync',
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const response = await fetch(MAPPING_URL);
|
const response = await axios.get(MAPPING_URL, {
|
||||||
if (!response.ok) {
|
responseType: 'stream',
|
||||||
throw new Error(`Failed to fetch: ${response.statusText}`);
|
});
|
||||||
}
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const writer = fs.createWriteStream(LOCAL_PATH);
|
const writer = fs.createWriteStream(LOCAL_PATH);
|
||||||
writer.on('finish', resolve);
|
writer.on('finish', resolve);
|
||||||
writer.on('error', reject);
|
writer.on('error', reject);
|
||||||
if (!response.body) return reject();
|
response.data.pipe(writer);
|
||||||
Readable.fromWeb(response.body as ReadableStream<Uint8Array>).pipe(
|
|
||||||
writer
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`Failed to download Anime-List mapping: ${e.message}`);
|
throw new Error(`Failed to download Anime-List mapping: ${e.message}`);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { MediaServerType } from '@server/constants/server';
|
import { requestInterceptorFunction } from '@server/utils/customProxyAgent';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||||
import type { RateLimitOptions } from '@server/utils/rateLimit';
|
import axios from 'axios';
|
||||||
import rateLimit from '@server/utils/rateLimit';
|
import rateLimit from 'axios-rate-limit';
|
||||||
import type NodeCache from 'node-cache';
|
import type NodeCache from 'node-cache';
|
||||||
|
|
||||||
// 5 minute default TTL (in seconds)
|
// 5 minute default TTL (in seconds)
|
||||||
@@ -10,226 +10,103 @@ const DEFAULT_TTL = 300;
|
|||||||
// 10 seconds default rolling buffer (in ms)
|
// 10 seconds default rolling buffer (in ms)
|
||||||
const DEFAULT_ROLLING_BUFFER = 10000;
|
const DEFAULT_ROLLING_BUFFER = 10000;
|
||||||
|
|
||||||
interface ExternalAPIOptions {
|
export interface ExternalAPIOptions {
|
||||||
nodeCache?: NodeCache;
|
nodeCache?: NodeCache;
|
||||||
headers?: Record<string, unknown>;
|
headers?: Record<string, unknown>;
|
||||||
rateLimit?: RateLimitOptions;
|
rateLimit?: {
|
||||||
|
maxRPS: number;
|
||||||
|
maxRequests: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class ExternalAPI {
|
class ExternalAPI {
|
||||||
protected fetch: typeof fetch;
|
protected axios: AxiosInstance;
|
||||||
protected params: Record<string, string>;
|
|
||||||
protected defaultHeaders: { [key: string]: string };
|
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
private cache?: NodeCache;
|
private cache?: NodeCache;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
params: Record<string, string> = {},
|
params: Record<string, unknown>,
|
||||||
options: ExternalAPIOptions = {}
|
options: ExternalAPIOptions = {}
|
||||||
) {
|
) {
|
||||||
|
this.axios = axios.create({
|
||||||
|
baseURL: baseUrl,
|
||||||
|
params,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.axios.interceptors.request.use(requestInterceptorFunction);
|
||||||
|
|
||||||
if (options.rateLimit) {
|
if (options.rateLimit) {
|
||||||
this.fetch = rateLimit(fetch, options.rateLimit);
|
this.axios = rateLimit(this.axios, {
|
||||||
} else {
|
maxRequests: options.rateLimit.maxRequests,
|
||||||
this.fetch = fetch;
|
maxRPS: options.rateLimit.maxRPS,
|
||||||
}
|
});
|
||||||
|
|
||||||
const url = new URL(baseUrl);
|
|
||||||
|
|
||||||
const settings = getSettings();
|
|
||||||
|
|
||||||
this.defaultHeaders = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: 'application/json',
|
|
||||||
...((url.username || url.password) && {
|
|
||||||
Authorization: `Basic ${Buffer.from(
|
|
||||||
`${url.username}:${url.password}`
|
|
||||||
).toString('base64')}`,
|
|
||||||
}),
|
|
||||||
...(settings.main.mediaServerType === MediaServerType.EMBY && {
|
|
||||||
'Accept-Encoding': 'gzip',
|
|
||||||
}),
|
|
||||||
...options.headers,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (url.username || url.password) {
|
|
||||||
url.username = '';
|
|
||||||
url.password = '';
|
|
||||||
baseUrl = url.toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
this.params = params;
|
|
||||||
this.cache = options.nodeCache;
|
this.cache = options.nodeCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async get<T>(
|
protected async get<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
params?: Record<string, string>,
|
config?: AxiosRequestConfig,
|
||||||
ttl?: number,
|
ttl?: number
|
||||||
config?: RequestInit
|
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||||
...this.params,
|
...config?.params,
|
||||||
...params,
|
headers: config?.headers,
|
||||||
});
|
});
|
||||||
const cachedItem = this.cache?.get<T>(cacheKey);
|
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||||
if (cachedItem) {
|
if (cachedItem) {
|
||||||
return cachedItem;
|
return cachedItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = this.formatUrl(endpoint, params);
|
const response = await this.axios.get<T>(endpoint, config);
|
||||||
const response = await this.fetch(url, {
|
|
||||||
...config,
|
|
||||||
headers: {
|
|
||||||
...this.defaultHeaders,
|
|
||||||
...config?.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(
|
|
||||||
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
|
|
||||||
{
|
|
||||||
cause: response,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const data = await this.getDataFromResponse(response);
|
|
||||||
|
|
||||||
if (this.cache && ttl !== 0) {
|
if (this.cache && ttl !== 0) {
|
||||||
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
|
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async post<T>(
|
protected async post<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
data?: Record<string, unknown>,
|
data?: Record<string, unknown>,
|
||||||
params?: Record<string, string>,
|
config?: AxiosRequestConfig,
|
||||||
ttl?: number,
|
ttl?: number
|
||||||
config?: RequestInit
|
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||||
config: { ...this.params, ...params },
|
config: config?.params,
|
||||||
data,
|
...(data ? { data } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const cachedItem = this.cache?.get<T>(cacheKey);
|
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||||
if (cachedItem) {
|
if (cachedItem) {
|
||||||
return cachedItem;
|
return cachedItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = this.formatUrl(endpoint, params);
|
const response = await this.axios.post<T>(endpoint, data, config);
|
||||||
const response = await this.fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
...config,
|
|
||||||
headers: {
|
|
||||||
...this.defaultHeaders,
|
|
||||||
...config?.headers,
|
|
||||||
},
|
|
||||||
body: data ? JSON.stringify(data) : undefined,
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(
|
|
||||||
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
|
|
||||||
{
|
|
||||||
cause: response,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const resData = await this.getDataFromResponse(response);
|
|
||||||
|
|
||||||
if (this.cache && ttl !== 0) {
|
if (this.cache && ttl !== 0) {
|
||||||
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
|
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
return resData;
|
return response.data;
|
||||||
}
|
|
||||||
|
|
||||||
protected async put<T>(
|
|
||||||
endpoint: string,
|
|
||||||
data: Record<string, unknown>,
|
|
||||||
params?: Record<string, string>,
|
|
||||||
ttl?: number,
|
|
||||||
config?: RequestInit
|
|
||||||
): Promise<T> {
|
|
||||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
|
||||||
config: { ...this.params, ...params },
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
const cachedItem = this.cache?.get<T>(cacheKey);
|
|
||||||
if (cachedItem) {
|
|
||||||
return cachedItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = this.formatUrl(endpoint, params);
|
|
||||||
const response = await this.fetch(url, {
|
|
||||||
method: 'PUT',
|
|
||||||
...config,
|
|
||||||
headers: {
|
|
||||||
...this.defaultHeaders,
|
|
||||||
...config?.headers,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(
|
|
||||||
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
|
|
||||||
{
|
|
||||||
cause: response,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const resData = await this.getDataFromResponse(response);
|
|
||||||
|
|
||||||
if (this.cache && ttl !== 0) {
|
|
||||||
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
|
|
||||||
}
|
|
||||||
|
|
||||||
return resData;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async delete<T>(
|
|
||||||
endpoint: string,
|
|
||||||
params?: Record<string, string>,
|
|
||||||
config?: RequestInit
|
|
||||||
): Promise<T> {
|
|
||||||
const url = this.formatUrl(endpoint, params);
|
|
||||||
const response = await this.fetch(url, {
|
|
||||||
method: 'DELETE',
|
|
||||||
...config,
|
|
||||||
headers: {
|
|
||||||
...this.defaultHeaders,
|
|
||||||
...config?.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(
|
|
||||||
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
|
|
||||||
{
|
|
||||||
cause: response,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const data = await this.getDataFromResponse(response);
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async getRolling<T>(
|
protected async getRolling<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
params?: Record<string, string>,
|
config?: AxiosRequestConfig,
|
||||||
ttl?: number,
|
ttl?: number
|
||||||
config?: RequestInit,
|
|
||||||
overwriteBaseUrl?: string
|
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||||
...this.params,
|
...config?.params,
|
||||||
...params,
|
headers: config?.headers,
|
||||||
});
|
});
|
||||||
const cachedItem = this.cache?.get<T>(cacheKey);
|
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||||
|
|
||||||
@@ -241,120 +118,38 @@ class ExternalAPI {
|
|||||||
keyTtl - (ttl ?? DEFAULT_TTL) * 1000 <
|
keyTtl - (ttl ?? DEFAULT_TTL) * 1000 <
|
||||||
Date.now() - DEFAULT_ROLLING_BUFFER
|
Date.now() - DEFAULT_ROLLING_BUFFER
|
||||||
) {
|
) {
|
||||||
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
|
this.axios.get<T>(endpoint, config).then((response) => {
|
||||||
this.fetch(url, {
|
this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
||||||
...config,
|
|
||||||
headers: {
|
|
||||||
...this.defaultHeaders,
|
|
||||||
...config?.headers,
|
|
||||||
},
|
|
||||||
}).then(async (response) => {
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(
|
|
||||||
`${response.status} ${response.statusText}${
|
|
||||||
text ? ': ' + text : ''
|
|
||||||
}`,
|
|
||||||
{
|
|
||||||
cause: response,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const data = await this.getDataFromResponse(response);
|
|
||||||
this.cache?.set(cacheKey, data, ttl ?? DEFAULT_TTL);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return cachedItem;
|
return cachedItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
|
const response = await this.axios.get<T>(endpoint, config);
|
||||||
const response = await this.fetch(url, {
|
|
||||||
...config,
|
|
||||||
headers: {
|
|
||||||
...this.defaultHeaders,
|
|
||||||
...config?.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(
|
|
||||||
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
|
|
||||||
{
|
|
||||||
cause: response,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const data = await this.getDataFromResponse(response);
|
|
||||||
|
|
||||||
if (this.cache) {
|
if (this.cache && ttl !== 0) {
|
||||||
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
|
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected removeCache(endpoint: string, params?: Record<string, string>) {
|
protected removeCache(endpoint: string, options?: Record<string, unknown>) {
|
||||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||||
...this.params,
|
...options,
|
||||||
...params,
|
|
||||||
});
|
});
|
||||||
this.cache?.del(cacheKey);
|
this.cache?.del(cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatUrl(
|
|
||||||
endpoint: string,
|
|
||||||
params?: Record<string, string>,
|
|
||||||
overwriteBaseUrl?: string
|
|
||||||
): string {
|
|
||||||
const baseUrl = overwriteBaseUrl || this.baseUrl;
|
|
||||||
const href =
|
|
||||||
baseUrl +
|
|
||||||
(baseUrl.endsWith('/') ? '' : '/') +
|
|
||||||
(endpoint.startsWith('/') ? endpoint.slice(1) : endpoint);
|
|
||||||
const searchParams = new URLSearchParams({
|
|
||||||
...this.params,
|
|
||||||
...params,
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
href +
|
|
||||||
(searchParams.toString().length
|
|
||||||
? '?' + searchParams.toString()
|
|
||||||
: searchParams.toString())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private serializeCacheKey(
|
private serializeCacheKey(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
params?: Record<string, unknown>
|
options?: Record<string, unknown>
|
||||||
) {
|
) {
|
||||||
if (!params) {
|
if (!options) {
|
||||||
return `${this.baseUrl}${endpoint}`;
|
return `${this.baseUrl}${endpoint}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`;
|
return `${this.baseUrl}${endpoint}${JSON.stringify(options)}`;
|
||||||
}
|
|
||||||
|
|
||||||
private async getDataFromResponse(response: Response) {
|
|
||||||
const contentType = response.headers.get('Content-Type');
|
|
||||||
if (contentType?.includes('application/json')) {
|
|
||||||
return await response.json();
|
|
||||||
} else if (
|
|
||||||
contentType?.includes('application/xml') ||
|
|
||||||
contentType?.includes('text/html') ||
|
|
||||||
contentType?.includes('text/plain')
|
|
||||||
) {
|
|
||||||
return await response.text();
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
return await response.json();
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
return await response.blob();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import ExternalAPI from '@server/api/externalapi';
|
|
||||||
import cacheManager from '@server/lib/cache';
|
import cacheManager from '@server/lib/cache';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import ExternalAPI from './externalapi';
|
||||||
|
|
||||||
interface GitHubRelease {
|
interface GitHubRelease {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -67,12 +67,16 @@ class GithubAPI extends ExternalAPI {
|
|||||||
'https://api.github.com',
|
'https://api.github.com',
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
nodeCache: cacheManager.getCache('github').data,
|
nodeCache: cacheManager.getCache('github').data,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getOverseerrReleases({
|
public async getJellyseerrReleases({
|
||||||
take = 20,
|
take = 20,
|
||||||
}: {
|
}: {
|
||||||
take?: number;
|
take?: number;
|
||||||
@@ -81,21 +85,23 @@ class GithubAPI extends ExternalAPI {
|
|||||||
const data = await this.get<GitHubRelease[]>(
|
const data = await this.get<GitHubRelease[]>(
|
||||||
'/repos/fallenbagel/jellyseerr/releases',
|
'/repos/fallenbagel/jellyseerr/releases',
|
||||||
{
|
{
|
||||||
per_page: take.toString(),
|
params: {
|
||||||
|
per_page: take,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
"Failed to retrieve GitHub releases. This may be an issue on GitHub's end. Overseerr can't check if it's on the latest version.",
|
"Failed to retrieve GitHub releases. This may be an issue on GitHub's end. Jellyseerr can't check if it's on the latest version.",
|
||||||
{ label: 'GitHub API', errorMessage: e.message }
|
{ label: 'GitHub API', errorMessage: e.message }
|
||||||
);
|
);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getOverseerrCommits({
|
public async getJellyseerrCommits({
|
||||||
take = 20,
|
take = 20,
|
||||||
branch = 'develop',
|
branch = 'develop',
|
||||||
}: {
|
}: {
|
||||||
@@ -106,15 +112,17 @@ class GithubAPI extends ExternalAPI {
|
|||||||
const data = await this.get<GithubCommit[]>(
|
const data = await this.get<GithubCommit[]>(
|
||||||
'/repos/fallenbagel/jellyseerr/commits',
|
'/repos/fallenbagel/jellyseerr/commits',
|
||||||
{
|
{
|
||||||
per_page: take.toString(),
|
params: {
|
||||||
branch,
|
per_page: take,
|
||||||
|
branch,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
"Failed to retrieve GitHub commits. This may be an issue on GitHub's end. Overseerr can't check if it's on the latest version.",
|
"Failed to retrieve GitHub commits. This may be an issue on GitHub's end. Jellyseerr can't check if it's on the latest version.",
|
||||||
{ label: 'GitHub API', errorMessage: e.message }
|
{ label: 'GitHub API', errorMessage: e.message }
|
||||||
);
|
);
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import ExternalAPI from '@server/api/externalapi';
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
import { ApiErrorCode } from '@server/constants/error';
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import availabilitySync from '@server/lib/availabilitySync';
|
import availabilitySync from '@server/lib/availabilitySync';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { ApiError } from '@server/types/error';
|
import { ApiError } from '@server/types/error';
|
||||||
import { getAppVersion } from '@server/utils/appVersion';
|
import { getAppVersion } from '@server/utils/appVersion';
|
||||||
@@ -20,6 +22,23 @@ export interface JellyfinUserResponse {
|
|||||||
PrimaryImageTag?: string;
|
PrimaryImageTag?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JellyfinDevice {
|
||||||
|
Id: string;
|
||||||
|
Name: string;
|
||||||
|
LastUserName: string;
|
||||||
|
AppName: string;
|
||||||
|
AppVersion: string;
|
||||||
|
LastUserId: string;
|
||||||
|
DateLastActivity: string;
|
||||||
|
Capabilities: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JellyfinDevicesResponse {
|
||||||
|
Items: JellyfinDevice[];
|
||||||
|
TotalRecordCount: number;
|
||||||
|
StartIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface JellyfinLoginResponse {
|
export interface JellyfinLoginResponse {
|
||||||
User: JellyfinUserResponse;
|
User: JellyfinUserResponse;
|
||||||
AccessToken: string;
|
AccessToken: string;
|
||||||
@@ -92,15 +111,32 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
|
|||||||
DateCreated?: string;
|
DateCreated?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JellyfinItemsReponse {
|
||||||
|
Items: JellyfinLibraryItemExtended[];
|
||||||
|
TotalRecordCount: number;
|
||||||
|
StartIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
class JellyfinAPI extends ExternalAPI {
|
class JellyfinAPI extends ExternalAPI {
|
||||||
private userId?: string;
|
private userId?: string;
|
||||||
|
private mediaServerType: MediaServerType;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
jellyfinHost: string,
|
||||||
|
authToken?: string | null,
|
||||||
|
deviceId?: string | null
|
||||||
|
) {
|
||||||
|
const settings = getSettings();
|
||||||
|
const safeDeviceId =
|
||||||
|
deviceId && deviceId.length > 0
|
||||||
|
? deviceId
|
||||||
|
: Buffer.from('BOT_jellyseerr').toString('base64');
|
||||||
|
|
||||||
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
|
|
||||||
let authHeaderVal: string;
|
let authHeaderVal: string;
|
||||||
if (authToken) {
|
if (authToken) {
|
||||||
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`;
|
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${safeDeviceId}", Version="${getAppVersion()}", Token="${authToken}"`;
|
||||||
} else {
|
} else {
|
||||||
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}"`;
|
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${safeDeviceId}", Version="${getAppVersion()}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
super(
|
super(
|
||||||
@@ -109,9 +145,13 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'X-Emby-Authorization': authHeaderVal,
|
'X-Emby-Authorization': authHeaderVal,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.mediaServerType = settings.main.mediaServerType;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async login(
|
public async login(
|
||||||
@@ -120,7 +160,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
ClientIP?: string
|
ClientIP?: string
|
||||||
): Promise<JellyfinLoginResponse> {
|
): Promise<JellyfinLoginResponse> {
|
||||||
const authenticate = async (useHeaders: boolean) => {
|
const authenticate = async (useHeaders: boolean) => {
|
||||||
const headers: { [key: string]: string } =
|
const headers =
|
||||||
useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {};
|
useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {};
|
||||||
|
|
||||||
return this.post<JellyfinLoginResponse>(
|
return this.post<JellyfinLoginResponse>(
|
||||||
@@ -129,8 +169,6 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
Username,
|
Username,
|
||||||
Pw: Password,
|
Pw: Password,
|
||||||
},
|
},
|
||||||
{},
|
|
||||||
undefined,
|
|
||||||
{ headers }
|
{ headers }
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -140,36 +178,36 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.debug('Failed to authenticate with headers', {
|
logger.debug('Failed to authenticate with headers', {
|
||||||
label: 'Jellyfin API',
|
label: 'Jellyfin API',
|
||||||
error: e.cause.message ?? e.cause.statusText,
|
error: e.response?.statusText,
|
||||||
ip: ClientIP,
|
ip: ClientIP,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!e.cause.status) {
|
if (!e.response?.status) {
|
||||||
throw new ApiError(404, ApiErrorCode.InvalidUrl);
|
throw new ApiError(404, ApiErrorCode.InvalidUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.cause.status === 401) {
|
if (e.response?.status === 401) {
|
||||||
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidCredentials);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await authenticate(false);
|
return await authenticate(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.cause.status === 401) {
|
if (e.response?.status === 401) {
|
||||||
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidCredentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while authenticating with the Jellyfin server',
|
`Something went wrong while authenticating with the Jellyfin server: ${e.message}`,
|
||||||
{
|
{
|
||||||
label: 'Jellyfin API',
|
label: 'Jellyfin API',
|
||||||
error: e.cause.message ?? e.cause.statusText,
|
error: e.response?.status,
|
||||||
ip: ClientIP,
|
ip: ClientIP,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause.status, ApiErrorCode.Unknown);
|
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +222,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
|
|
||||||
return systemInfoResponse;
|
return systemInfoResponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,11 +235,11 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return serverResponse.ServerName;
|
return serverResponse.ServerName;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the server name from the Jellyfin server',
|
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API', error: e.response?.status }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
|
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,11 +250,11 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return { users: userReponse };
|
return { users: userReponse };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the account from the Jellyfin server',
|
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API', error: e.response?.status }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,11 +266,11 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return userReponse;
|
return userReponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the account from the Jellyfin server',
|
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API', error: e.response?.status }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,10 +290,10 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return this.mapLibraries(mediaFolderResponse.Items);
|
return this.mapLibraries(mediaFolderResponse.Items);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting libraries from the Jellyfin server',
|
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
|
||||||
{
|
{
|
||||||
label: 'Jellyfin API',
|
label: 'Jellyfin API',
|
||||||
error: e.cause.message ?? e.cause.statusText,
|
error: e.response?.status,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -293,16 +331,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
|
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
|
||||||
try {
|
try {
|
||||||
const libraryItemsResponse = await this.get<any>(
|
const libraryItemsResponse = await this.get<any>(
|
||||||
`/Users/${this.userId}/Items`,
|
`/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false`
|
||||||
{
|
|
||||||
SortBy: 'SortName',
|
|
||||||
SortOrder: 'Ascending',
|
|
||||||
IncludeItemTypes: 'Series,Movie,Others',
|
|
||||||
Recursive: 'true',
|
|
||||||
StartIndex: '0',
|
|
||||||
ParentId: id,
|
|
||||||
collapseBoxSetItems: 'false',
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return libraryItemsResponse.Items.filter(
|
return libraryItemsResponse.Items.filter(
|
||||||
@@ -310,32 +339,36 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting library content from the Jellyfin server',
|
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API', error: e?.response?.status }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> {
|
public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> {
|
||||||
try {
|
try {
|
||||||
|
const endpoint =
|
||||||
|
this.mediaServerType === MediaServerType.JELLYFIN
|
||||||
|
? `/Items/Latest`
|
||||||
|
: `/Users/${this.userId}/Items/Latest`;
|
||||||
const itemResponse = await this.get<any>(
|
const itemResponse = await this.get<any>(
|
||||||
`/Users/${this.userId}/Items/Latest`,
|
`${endpoint}?Limit=12&ParentId=${id}${
|
||||||
{
|
this.mediaServerType === MediaServerType.JELLYFIN
|
||||||
Limit: '12',
|
? `&userId=${this.userId ?? 'Me'}`
|
||||||
ParentId: id,
|
: ''
|
||||||
}
|
}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return itemResponse;
|
return itemResponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting library content from the Jellyfin server',
|
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API', error: e.response?.status }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,23 +376,26 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
id: string
|
id: string
|
||||||
): Promise<JellyfinLibraryItemExtended | undefined> {
|
): Promise<JellyfinLibraryItemExtended | undefined> {
|
||||||
try {
|
try {
|
||||||
const itemResponse = await this.get<any>(
|
const itemResponse = await this.get<JellyfinItemsReponse>(`/Items`, {
|
||||||
`/Users/${this.userId}/Items/${id}`
|
params: {
|
||||||
);
|
ids: id,
|
||||||
|
fields: 'ProviderIds,MediaSources,Width,Height,IsHD,DateCreated',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return itemResponse;
|
return itemResponse.Items?.[0];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (availabilitySync.running) {
|
if (availabilitySync.running) {
|
||||||
if (e.cause?.status === 500) {
|
if (e.response?.status === 500) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting library content from the Jellyfin server',
|
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API', error: e.response?.status }
|
||||||
);
|
);
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,11 +406,11 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return seasonResponse.Items;
|
return seasonResponse.Items;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the list of seasons from the Jellyfin server',
|
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API', error: e.response?.status }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,10 +420,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
): Promise<JellyfinLibraryItem[]> {
|
): Promise<JellyfinLibraryItem[]> {
|
||||||
try {
|
try {
|
||||||
const episodeResponse = await this.get<any>(
|
const episodeResponse = await this.get<any>(
|
||||||
`/Shows/${seriesID}/Episodes`,
|
`/Shows/${seriesID}/Episodes?seasonId=${seasonID}`
|
||||||
{
|
|
||||||
seasonId: seasonID,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return episodeResponse.Items.filter(
|
return episodeResponse.Items.filter(
|
||||||
@@ -395,11 +428,11 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the list of episodes from the Jellyfin server',
|
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API', error: e.response?.status }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,8 +445,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
).AccessToken;
|
).AccessToken;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while creating an API key from the Jellyfin server',
|
`Something went wrong while creating an API key from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API', error: e.response?.status }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
|
|||||||
39
server/api/metadata.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { TvShowProvider } from '@server/api/provider';
|
||||||
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
|
import Tvdb from '@server/api/tvdb';
|
||||||
|
import { getSettings, MetadataProviderType } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
|
||||||
|
export const getMetadataProvider = async (
|
||||||
|
mediaType: 'movie' | 'tv' | 'anime'
|
||||||
|
): Promise<TvShowProvider> => {
|
||||||
|
try {
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
if (mediaType == 'movie') {
|
||||||
|
return new TheMovieDb();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
mediaType == 'tv' &&
|
||||||
|
settings.metadataSettings.tv == MetadataProviderType.TVDB
|
||||||
|
) {
|
||||||
|
return await Tvdb.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
mediaType == 'anime' &&
|
||||||
|
settings.metadataSettings.anime == MetadataProviderType.TVDB
|
||||||
|
) {
|
||||||
|
return await Tvdb.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TheMovieDb();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to get metadata provider', {
|
||||||
|
label: 'Metadata',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
return new TheMovieDb();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -92,7 +92,7 @@ class PlexAPI {
|
|||||||
plexSettings,
|
plexSettings,
|
||||||
timeout,
|
timeout,
|
||||||
}: {
|
}: {
|
||||||
plexToken?: string;
|
plexToken?: string | null;
|
||||||
plexSettings?: PlexSettings;
|
plexSettings?: PlexSettings;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
}) {
|
}) {
|
||||||
@@ -107,7 +107,7 @@ class PlexAPI {
|
|||||||
port: settingsPlex.port,
|
port: settingsPlex.port,
|
||||||
https: settingsPlex.useSsl,
|
https: settingsPlex.useSsl,
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
token: plexToken,
|
token: plexToken ?? undefined,
|
||||||
authenticator: {
|
authenticator: {
|
||||||
authenticate: (
|
authenticate: (
|
||||||
_plexApi,
|
_plexApi,
|
||||||
@@ -124,9 +124,9 @@ class PlexAPI {
|
|||||||
// },
|
// },
|
||||||
options: {
|
options: {
|
||||||
identifier: settings.clientId,
|
identifier: settings.clientId,
|
||||||
product: 'Overseerr',
|
product: 'Jellyseerr',
|
||||||
deviceName: 'Overseerr',
|
deviceName: 'Jellyseerr',
|
||||||
platform: 'Overseerr',
|
platform: 'Jellyseerr',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import ExternalAPI from '@server/api/externalapi';
|
|
||||||
import type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
|
import type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
|
||||||
import cacheManager from '@server/lib/cache';
|
import cacheManager from '@server/lib/cache';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import xml2js from 'xml2js';
|
import xml2js from 'xml2js';
|
||||||
|
import ExternalAPI from './externalapi';
|
||||||
|
|
||||||
interface PlexAccountResponse {
|
interface PlexAccountResponse {
|
||||||
user: PlexUser;
|
user: PlexUser;
|
||||||
@@ -143,6 +143,8 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'X-Plex-Token': authToken,
|
'X-Plex-Token': authToken,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
nodeCache: cacheManager.getCache('plextv').data,
|
nodeCache: cacheManager.getCache('plextv').data,
|
||||||
}
|
}
|
||||||
@@ -153,11 +155,15 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
|
|
||||||
public async getDevices(): Promise<PlexDevice[]> {
|
public async getDevices(): Promise<PlexDevice[]> {
|
||||||
try {
|
try {
|
||||||
const devicesResp = await this.get('/api/resources', {
|
const devicesResp = await this.axios.get(
|
||||||
includeHttps: '1',
|
'/api/resources?includeHttps=1',
|
||||||
});
|
{
|
||||||
|
transformResponse: [],
|
||||||
|
responseType: 'text',
|
||||||
|
}
|
||||||
|
);
|
||||||
const parsedXml = await xml2js.parseStringPromise(
|
const parsedXml = await xml2js.parseStringPromise(
|
||||||
devicesResp as DeviceResponse
|
devicesResp.data as DeviceResponse
|
||||||
);
|
);
|
||||||
return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({
|
return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({
|
||||||
name: pxml.$.name,
|
name: pxml.$.name,
|
||||||
@@ -205,11 +211,11 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
|
|
||||||
public async getUser(): Promise<PlexUser> {
|
public async getUser(): Promise<PlexUser> {
|
||||||
try {
|
try {
|
||||||
const account = await this.get<PlexAccountResponse>(
|
const account = await this.axios.get<PlexAccountResponse>(
|
||||||
'/users/account.json'
|
'/users/account.json'
|
||||||
);
|
);
|
||||||
|
|
||||||
return account.user;
|
return account.data.user;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting the account from plex.tv: ${e.message}`,
|
`Something went wrong while getting the account from plex.tv: ${e.message}`,
|
||||||
@@ -249,10 +255,13 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getUsers(): Promise<UsersResponse> {
|
public async getUsers(): Promise<UsersResponse> {
|
||||||
const data = await this.get('/api/users');
|
const response = await this.axios.get('/api/users', {
|
||||||
|
transformResponse: [],
|
||||||
|
responseType: 'text',
|
||||||
|
});
|
||||||
|
|
||||||
const parsedXml = (await xml2js.parseStringPromise(
|
const parsedXml = (await xml2js.parseStringPromise(
|
||||||
data as string
|
response.data
|
||||||
)) as UsersResponse;
|
)) as UsersResponse;
|
||||||
return parsedXml;
|
return parsedXml;
|
||||||
}
|
}
|
||||||
@@ -272,28 +281,26 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
this.authToken
|
this.authToken
|
||||||
);
|
);
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const response = await this.axios.get<WatchlistResponse>(
|
||||||
'X-Plex-Container-Start': offset.toString(),
|
'/library/sections/watchlist/all',
|
||||||
'X-Plex-Container-Size': size.toString(),
|
|
||||||
});
|
|
||||||
const response = await this.fetch(
|
|
||||||
`https://metadata.provider.plex.tv/library/sections/watchlist/all?${params.toString()}`,
|
|
||||||
{
|
{
|
||||||
headers: {
|
params: {
|
||||||
...this.defaultHeaders,
|
'X-Plex-Container-Start': offset,
|
||||||
...(cachedWatchlist?.etag
|
'X-Plex-Container-Size': size,
|
||||||
? { 'If-None-Match': cachedWatchlist.etag }
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
|
headers: {
|
||||||
|
'If-None-Match': cachedWatchlist?.etag,
|
||||||
|
},
|
||||||
|
baseURL: 'https://discover.provider.plex.tv',
|
||||||
|
validateStatus: (status) => status < 400, // Allow HTTP 304 to return without error
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const data = (await response.json()) as WatchlistResponse;
|
|
||||||
|
|
||||||
// If we don't recieve HTTP 304, the watchlist has been updated and we need to update the cache.
|
// If we don't recieve HTTP 304, the watchlist has been updated and we need to update the cache.
|
||||||
if (response.status >= 200 && response.status <= 299) {
|
if (response.status >= 200 && response.status <= 299) {
|
||||||
cachedWatchlist = {
|
cachedWatchlist = {
|
||||||
etag: response.headers.get('etag') ?? '',
|
etag: response.headers.etag,
|
||||||
response: data,
|
response: response.data,
|
||||||
};
|
};
|
||||||
|
|
||||||
watchlistCache.data.set<PlexWatchlistCache>(
|
watchlistCache.data.set<PlexWatchlistCache>(
|
||||||
@@ -307,10 +314,9 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
async (watchlistItem) => {
|
async (watchlistItem) => {
|
||||||
const detailedResponse = await this.getRolling<MetadataResponse>(
|
const detailedResponse = await this.getRolling<MetadataResponse>(
|
||||||
`/library/metadata/${watchlistItem.ratingKey}`,
|
`/library/metadata/${watchlistItem.ratingKey}`,
|
||||||
{},
|
{
|
||||||
undefined,
|
baseURL: 'https://discover.provider.plex.tv',
|
||||||
{},
|
}
|
||||||
'https://metadata.provider.plex.tv'
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const metadata = detailedResponse.MediaContainer.Metadata[0];
|
const metadata = detailedResponse.MediaContainer.Metadata[0];
|
||||||
@@ -361,17 +367,12 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
|
|
||||||
public async pingToken() {
|
public async pingToken() {
|
||||||
try {
|
try {
|
||||||
const data: { pong: unknown } = await this.get(
|
const response = await this.axios.get('/api/v2/ping', {
|
||||||
'/api/v2/ping',
|
headers: {
|
||||||
{},
|
'X-Plex-Client-Identifier': randomUUID(),
|
||||||
undefined,
|
},
|
||||||
{
|
});
|
||||||
headers: {
|
if (!response?.data?.pong) {
|
||||||
'X-Plex-Client-Identifier': randomUUID(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!data?.pong) {
|
|
||||||
throw new Error('No pong response');
|
throw new Error('No pong response');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
30
server/api/provider.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type {
|
||||||
|
TmdbSeasonWithEpisodes,
|
||||||
|
TmdbTvDetails,
|
||||||
|
} from '@server/api/themoviedb/interfaces';
|
||||||
|
|
||||||
|
export interface TvShowProvider {
|
||||||
|
getTvShow({
|
||||||
|
tvId,
|
||||||
|
language,
|
||||||
|
}: {
|
||||||
|
tvId: number;
|
||||||
|
language?: string;
|
||||||
|
}): Promise<TmdbTvDetails>;
|
||||||
|
getTvSeason({
|
||||||
|
tvId,
|
||||||
|
seasonNumber,
|
||||||
|
language,
|
||||||
|
}: {
|
||||||
|
tvId: number;
|
||||||
|
seasonNumber: number;
|
||||||
|
language?: string;
|
||||||
|
}): Promise<TmdbSeasonWithEpisodes>;
|
||||||
|
getShowByTvdbId({
|
||||||
|
tvdbId,
|
||||||
|
language,
|
||||||
|
}: {
|
||||||
|
tvdbId: number;
|
||||||
|
language?: string;
|
||||||
|
}): Promise<TmdbTvDetails>;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import ExternalAPI from '@server/api/externalapi';
|
import ExternalAPI from './externalapi';
|
||||||
|
|
||||||
interface PushoverSoundsResponse {
|
interface PushoverSoundsResponse {
|
||||||
sounds: {
|
sounds: {
|
||||||
@@ -26,13 +26,24 @@ export const mapSounds = (sounds: {
|
|||||||
|
|
||||||
class PushoverAPI extends ExternalAPI {
|
class PushoverAPI extends ExternalAPI {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('https://api.pushover.net/1');
|
super(
|
||||||
|
'https://api.pushover.net/1',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getSounds(appToken: string): Promise<PushoverSound[]> {
|
public async getSounds(appToken: string): Promise<PushoverSound[]> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<PushoverSoundsResponse>('/sounds.json', {
|
const data = await this.get<PushoverSoundsResponse>('/sounds.json', {
|
||||||
token: appToken,
|
params: {
|
||||||
|
token: appToken,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return mapSounds(data.sounds);
|
return mapSounds(data.sounds);
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export interface IMDBRating {
|
|||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
criticsScore: number;
|
criticsScore: number;
|
||||||
|
criticsScoreCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -155,13 +156,13 @@ export interface IMDBRating {
|
|||||||
*/
|
*/
|
||||||
class IMDBRadarrProxy extends ExternalAPI {
|
class IMDBRadarrProxy extends ExternalAPI {
|
||||||
constructor() {
|
constructor() {
|
||||||
super(
|
super('https://api.radarr.video/v1', {
|
||||||
'https://api.radarr.video/v1',
|
headers: {
|
||||||
{},
|
'Content-Type': 'application/json',
|
||||||
{
|
Accept: 'application/json',
|
||||||
nodeCache: cacheManager.getCache('imdb').data,
|
},
|
||||||
}
|
nodeCache: cacheManager.getCache('imdb').data,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,6 +188,7 @@ class IMDBRadarrProxy extends ExternalAPI {
|
|||||||
title: data[0].Title,
|
title: data[0].Title,
|
||||||
url: `https://www.imdb.com/title/${data[0].ImdbId}`,
|
url: `https://www.imdb.com/title/${data[0].ImdbId}`,
|
||||||
criticsScore: data[0].MovieRatings.Imdb.Value,
|
criticsScore: data[0].MovieRatings.Imdb.Value,
|
||||||
|
criticsScoreCount: data[0].MovieRatings.Imdb.Count,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import ExternalAPI from '@server/api/externalapi';
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
import cacheManager from '@server/lib/cache';
|
import cacheManager from '@server/lib/cache';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import jaro from 'wink-jaro-distance';
|
||||||
|
|
||||||
interface RTAlgoliaSearchResponse {
|
interface RTAlgoliaSearchResponse {
|
||||||
results: {
|
results: {
|
||||||
@@ -15,7 +16,7 @@ interface RTAlgoliaHit {
|
|||||||
tmsId: string;
|
tmsId: string;
|
||||||
type: string;
|
type: string;
|
||||||
title: string;
|
title: string;
|
||||||
titles: string[];
|
titles?: string[];
|
||||||
description: string;
|
description: string;
|
||||||
releaseYear: number;
|
releaseYear: number;
|
||||||
rating: string;
|
rating: string;
|
||||||
@@ -24,9 +25,9 @@ interface RTAlgoliaHit {
|
|||||||
isEmsSearchable: boolean;
|
isEmsSearchable: boolean;
|
||||||
rtId: number;
|
rtId: number;
|
||||||
vanity: string;
|
vanity: string;
|
||||||
aka: string[];
|
aka?: string[];
|
||||||
posterImageUrl: string;
|
posterImageUrl: string;
|
||||||
rottenTomatoes: {
|
rottenTomatoes?: {
|
||||||
audienceScore: number;
|
audienceScore: number;
|
||||||
criticsIconUrl: string;
|
criticsIconUrl: string;
|
||||||
wantToSeeCount: number;
|
wantToSeeCount: number;
|
||||||
@@ -47,6 +48,47 @@ export interface RTRating {
|
|||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tunables
|
||||||
|
const INEXACT_TITLE_FACTOR = 0.25;
|
||||||
|
const ALTERNATE_TITLE_FACTOR = 0.8;
|
||||||
|
const PER_YEAR_PENALTY = 0.4;
|
||||||
|
const MINIMUM_SCORE = 0.175;
|
||||||
|
|
||||||
|
// Normalization for title comparisons.
|
||||||
|
// Lowercase and strip non-alphanumeric (unicode-aware).
|
||||||
|
const norm = (s: string): string =>
|
||||||
|
s.toLowerCase().replace(/[^\p{L}\p{N} ]/gu, '');
|
||||||
|
|
||||||
|
// Title similarity. 1 if exact, quarter-jaro otherwise.
|
||||||
|
const similarity = (a: string, b: string): number =>
|
||||||
|
a === b ? 1 : jaro(a, b).similarity * INEXACT_TITLE_FACTOR;
|
||||||
|
|
||||||
|
// Gets the best similarity score between the searched title and all alternate
|
||||||
|
// titles of the search result. Non-main titles are penalized.
|
||||||
|
const t_score = ({ title, titles, aka }: RTAlgoliaHit, s: string): number => {
|
||||||
|
const f = (t: string, i: number) =>
|
||||||
|
similarity(norm(t), norm(s)) * (i ? ALTERNATE_TITLE_FACTOR : 1);
|
||||||
|
return Math.max(...[title].concat(aka || [], titles || []).map(f));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Year difference to score: 0 -> 1.0, 1 -> 0.6, 2 -> 0.2, 3+ -> 0.0
|
||||||
|
const y_score = (r: RTAlgoliaHit, y?: number): number =>
|
||||||
|
y ? Math.max(0, 1 - Math.abs(r.releaseYear - y) * PER_YEAR_PENALTY) : 1;
|
||||||
|
|
||||||
|
// Cut score in half if result has no ratings.
|
||||||
|
const extra_score = (r: RTAlgoliaHit): number => (r.rottenTomatoes ? 1 : 0.5);
|
||||||
|
|
||||||
|
// Score search result as product of all subscores
|
||||||
|
const score = (r: RTAlgoliaHit, name: string, year?: number): number =>
|
||||||
|
t_score(r, name) * y_score(r, year) * extra_score(r);
|
||||||
|
|
||||||
|
// Score each search result and return the highest scoring result, if any
|
||||||
|
const best = (rs: RTAlgoliaHit[], name: string, year?: number): RTAlgoliaHit =>
|
||||||
|
rs
|
||||||
|
.map((r) => ({ score: score(r, name, year), result: r }))
|
||||||
|
.filter(({ score }) => score > MINIMUM_SCORE)
|
||||||
|
.sort(({ score: a }, { score: b }) => b - a)[0]?.result;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is a best-effort API. The Rotten Tomatoes API is technically
|
* This is a best-effort API. The Rotten Tomatoes API is technically
|
||||||
* private and getting access costs money/requires approval.
|
* private and getting access costs money/requires approval.
|
||||||
@@ -63,12 +105,15 @@ class RottenTomatoes extends ExternalAPI {
|
|||||||
super(
|
super(
|
||||||
'https://79frdp12pn-dsn.algolia.net/1/indexes/*',
|
'https://79frdp12pn-dsn.algolia.net/1/indexes/*',
|
||||||
{
|
{
|
||||||
'x-algolia-agent': 'Algolia for JavaScript (4.14.3); Browser (lite)',
|
'x-algolia-agent':
|
||||||
|
'Algolia%20for%20JavaScript%20(4.14.3)%3B%20Browser%20(lite)',
|
||||||
'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561',
|
'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561',
|
||||||
'x-algolia-application-id': '79FRDP12PN',
|
'x-algolia-application-id': '79FRDP12PN',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
'x-algolia-usertoken': settings.clientId,
|
'x-algolia-usertoken': settings.clientId,
|
||||||
},
|
},
|
||||||
nodeCache: cacheManager.getCache('rt').data,
|
nodeCache: cacheManager.getCache('rt').data,
|
||||||
@@ -90,47 +135,21 @@ class RottenTomatoes extends ExternalAPI {
|
|||||||
year: number
|
year: number
|
||||||
): Promise<RTRating | null> {
|
): Promise<RTRating | null> {
|
||||||
try {
|
try {
|
||||||
|
const filters = encodeURIComponent('isEmsSearchable=1 AND type:"movie"');
|
||||||
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
|
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
|
||||||
requests: [
|
requests: [
|
||||||
{
|
{
|
||||||
indexName: 'content_rt',
|
indexName: 'content_rt',
|
||||||
query: name,
|
query: name.replace(/\bthe\b ?/gi, ''),
|
||||||
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
|
params: `filters=${filters}&hitsPerPage=20`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const contentResults = data.results.find((r) => r.index === 'content_rt');
|
const contentResults = data.results.find((r) => r.index === 'content_rt');
|
||||||
|
const movie = best(contentResults?.hits || [], name, year);
|
||||||
|
|
||||||
if (!contentResults) {
|
if (!movie?.rottenTomatoes) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// First, attempt to match exact name and year
|
|
||||||
let movie = contentResults.hits.find(
|
|
||||||
(movie) => movie.releaseYear === year && movie.title === name
|
|
||||||
);
|
|
||||||
|
|
||||||
// If we don't find a movie, try to match partial name and year
|
|
||||||
if (!movie) {
|
|
||||||
movie = contentResults.hits.find(
|
|
||||||
(movie) => movie.releaseYear === year && movie.title.includes(name)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we still dont find a movie, try to match just on year
|
|
||||||
if (!movie) {
|
|
||||||
movie = contentResults.hits.find((movie) => movie.releaseYear === year);
|
|
||||||
}
|
|
||||||
|
|
||||||
// One last try, try exact name match only
|
|
||||||
if (!movie) {
|
|
||||||
movie = contentResults.hits.find((movie) => movie.title === name);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!movie?.rottenTomatoes) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: movie.title,
|
title: movie.title,
|
||||||
@@ -158,33 +177,21 @@ class RottenTomatoes extends ExternalAPI {
|
|||||||
year?: number
|
year?: number
|
||||||
): Promise<RTRating | null> {
|
): Promise<RTRating | null> {
|
||||||
try {
|
try {
|
||||||
|
const filters = encodeURIComponent('isEmsSearchable=1 AND type:"tv"');
|
||||||
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
|
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
|
||||||
requests: [
|
requests: [
|
||||||
{
|
{
|
||||||
indexName: 'content_rt',
|
indexName: 'content_rt',
|
||||||
query: name,
|
query: name,
|
||||||
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
|
params: `filters=${filters}&hitsPerPage=20`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const contentResults = data.results.find((r) => r.index === 'content_rt');
|
const contentResults = data.results.find((r) => r.index === 'content_rt');
|
||||||
|
const tvshow = best(contentResults?.hits || [], name, year);
|
||||||
|
|
||||||
if (!contentResults) {
|
if (!tvshow?.rottenTomatoes) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let tvshow: RTAlgoliaHit | undefined = contentResults.hits[0];
|
|
||||||
|
|
||||||
if (year) {
|
|
||||||
tvshow = contentResults.hits.find(
|
|
||||||
(series) => series.releaseYear === year
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tvshow || !tvshow.rottenTomatoes) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: tvshow.title,
|
title: tvshow.title,
|
||||||
|
|||||||
@@ -113,9 +113,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
|||||||
|
|
||||||
public getSystemStatus = async (): Promise<SystemStatus> => {
|
public getSystemStatus = async (): Promise<SystemStatus> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<SystemStatus>('/system/status');
|
const response = await this.axios.get<SystemStatus>('/system/status');
|
||||||
|
|
||||||
return data;
|
return response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[${this.apiName}] Failed to retrieve system status: ${e.message}`
|
`[${this.apiName}] Failed to retrieve system status: ${e.message}`
|
||||||
@@ -157,15 +157,16 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
|||||||
|
|
||||||
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
|
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<QueueResponse<QueueItemAppendT>>(
|
const response = await this.axios.get<QueueResponse<QueueItemAppendT>>(
|
||||||
`/queue`,
|
`/queue`,
|
||||||
{
|
{
|
||||||
includeEpisode: 'true',
|
params: {
|
||||||
},
|
includeEpisode: true,
|
||||||
0
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return data.records;
|
return response.data.records;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[${this.apiName}] Failed to retrieve queue: ${e.message}`
|
`[${this.apiName}] Failed to retrieve queue: ${e.message}`
|
||||||
@@ -175,9 +176,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
|||||||
|
|
||||||
public getTags = async (): Promise<Tag[]> => {
|
public getTags = async (): Promise<Tag[]> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<Tag[]>(`/tag`);
|
const response = await this.axios.get<Tag[]>(`/tag`);
|
||||||
|
|
||||||
return data;
|
return response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[${this.apiName}] Failed to retrieve tags: ${e.message}`
|
`[${this.apiName}] Failed to retrieve tags: ${e.message}`
|
||||||
@@ -187,11 +188,11 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
|||||||
|
|
||||||
public createTag = async ({ label }: { label: string }): Promise<Tag> => {
|
public createTag = async ({ label }: { label: string }): Promise<Tag> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.post<Tag>(`/tag`, {
|
const response = await this.axios.post<Tag>(`/tag`, {
|
||||||
label,
|
label,
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[${this.apiName}] Failed to create tag: ${e.message}`);
|
throw new Error(`[${this.apiName}] Failed to create tag: ${e.message}`);
|
||||||
}
|
}
|
||||||
@@ -206,15 +207,10 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
|||||||
options: Record<string, unknown>
|
options: Record<string, unknown>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.post(
|
await this.axios.post(`/command`, {
|
||||||
`/command`,
|
name: commandName,
|
||||||
{
|
...options,
|
||||||
name: commandName,
|
});
|
||||||
...options,
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
0
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
|
throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,39 @@ export interface RadarrMovie {
|
|||||||
qualityProfileId: number;
|
qualityProfileId: number;
|
||||||
added: string;
|
added: string;
|
||||||
hasFile: boolean;
|
hasFile: boolean;
|
||||||
|
tags: number[];
|
||||||
|
movieFile?: {
|
||||||
|
id: number;
|
||||||
|
movieId: number;
|
||||||
|
relativePath?: string;
|
||||||
|
path?: string;
|
||||||
|
size: number;
|
||||||
|
dateAdded: string;
|
||||||
|
sceneName?: string;
|
||||||
|
releaseGroup?: string;
|
||||||
|
edition?: string;
|
||||||
|
indexerFlags?: number;
|
||||||
|
mediaInfo: {
|
||||||
|
id: number;
|
||||||
|
audioBitrate: number;
|
||||||
|
audioChannels: number;
|
||||||
|
audioCodec?: string;
|
||||||
|
audioLanguages?: string;
|
||||||
|
audioStreamCount: number;
|
||||||
|
videoBitDepth: number;
|
||||||
|
videoBitrate: number;
|
||||||
|
videoCodec?: string;
|
||||||
|
videoFps: number;
|
||||||
|
videoDynamicRange?: string;
|
||||||
|
videoDynamicRangeType?: string;
|
||||||
|
resolution?: string;
|
||||||
|
runTime?: string;
|
||||||
|
scanType?: string;
|
||||||
|
subtitles?: string;
|
||||||
|
};
|
||||||
|
originalFilePath?: string;
|
||||||
|
qualityCutoffNotMet: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||||
@@ -37,9 +70,9 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
|
|
||||||
public getMovies = async (): Promise<RadarrMovie[]> => {
|
public getMovies = async (): Promise<RadarrMovie[]> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<RadarrMovie[]>('/movie');
|
const response = await this.axios.get<RadarrMovie[]>('/movie');
|
||||||
|
|
||||||
return data;
|
return response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[Radarr] Failed to retrieve movies: ${e.message}`);
|
throw new Error(`[Radarr] Failed to retrieve movies: ${e.message}`);
|
||||||
}
|
}
|
||||||
@@ -47,9 +80,9 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
|
|
||||||
public getMovie = async ({ id }: { id: number }): Promise<RadarrMovie> => {
|
public getMovie = async ({ id }: { id: number }): Promise<RadarrMovie> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<RadarrMovie>(`/movie/${id}`);
|
const response = await this.axios.get<RadarrMovie>(`/movie/${id}`);
|
||||||
|
|
||||||
return data;
|
return response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[Radarr] Failed to retrieve movie: ${e.message}`);
|
throw new Error(`[Radarr] Failed to retrieve movie: ${e.message}`);
|
||||||
}
|
}
|
||||||
@@ -57,15 +90,17 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
|
|
||||||
public async getMovieByTmdbId(id: number): Promise<RadarrMovie> {
|
public async getMovieByTmdbId(id: number): Promise<RadarrMovie> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<RadarrMovie[]>('/movie/lookup', {
|
const response = await this.axios.get<RadarrMovie[]>('/movie/lookup', {
|
||||||
term: `tmdb:${id}`,
|
params: {
|
||||||
|
term: `tmdb:${id}`,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data[0]) {
|
if (!response.data[0]) {
|
||||||
throw new Error('Movie not found');
|
throw new Error('Movie not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return data[0];
|
return response.data[0];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error retrieving movie by TMDB ID', {
|
logger.error('Error retrieving movie by TMDB ID', {
|
||||||
label: 'Radarr API',
|
label: 'Radarr API',
|
||||||
@@ -95,7 +130,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
|
|
||||||
// movie exists in Radarr but is neither downloaded nor monitored
|
// movie exists in Radarr but is neither downloaded nor monitored
|
||||||
if (movie.id && !movie.monitored) {
|
if (movie.id && !movie.monitored) {
|
||||||
const data = await this.put<RadarrMovie>(`/movie`, {
|
const response = await this.axios.put<RadarrMovie>(`/movie`, {
|
||||||
...movie,
|
...movie,
|
||||||
title: options.title,
|
title: options.title,
|
||||||
qualityProfileId: options.qualityProfileId,
|
qualityProfileId: options.qualityProfileId,
|
||||||
@@ -104,7 +139,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
minimumAvailability: options.minimumAvailability,
|
minimumAvailability: options.minimumAvailability,
|
||||||
tmdbId: options.tmdbId,
|
tmdbId: options.tmdbId,
|
||||||
year: options.year,
|
year: options.year,
|
||||||
tags: options.tags,
|
tags: Array.from(new Set([...movie.tags, ...options.tags])),
|
||||||
rootFolderPath: options.rootFolderPath,
|
rootFolderPath: options.rootFolderPath,
|
||||||
monitored: options.monitored,
|
monitored: options.monitored,
|
||||||
addOptions: {
|
addOptions: {
|
||||||
@@ -112,25 +147,25 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.monitored) {
|
if (response.data.monitored) {
|
||||||
logger.info(
|
logger.info(
|
||||||
'Found existing title in Radarr and set it to monitored.',
|
'Found existing title in Radarr and set it to monitored.',
|
||||||
{
|
{
|
||||||
label: 'Radarr',
|
label: 'Radarr',
|
||||||
movieId: data.id,
|
movieId: response.data.id,
|
||||||
movieTitle: data.title,
|
movieTitle: response.data.title,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
logger.debug('Radarr update details', {
|
logger.debug('Radarr update details', {
|
||||||
label: 'Radarr',
|
label: 'Radarr',
|
||||||
movie: data,
|
movie: response.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (options.searchNow) {
|
if (options.searchNow) {
|
||||||
this.searchMovie(data.id);
|
this.searchMovie(response.data.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return response.data;
|
||||||
} else {
|
} else {
|
||||||
logger.error('Failed to update existing movie in Radarr.', {
|
logger.error('Failed to update existing movie in Radarr.', {
|
||||||
label: 'Radarr',
|
label: 'Radarr',
|
||||||
@@ -148,7 +183,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
return movie;
|
return movie;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await this.post<RadarrMovie>(`/movie`, {
|
const response = await this.axios.post<RadarrMovie>(`/movie`, {
|
||||||
title: options.title,
|
title: options.title,
|
||||||
qualityProfileId: options.qualityProfileId,
|
qualityProfileId: options.qualityProfileId,
|
||||||
profileId: options.profileId,
|
profileId: options.profileId,
|
||||||
@@ -164,11 +199,11 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.id) {
|
if (response.data.id) {
|
||||||
logger.info('Radarr accepted request', { label: 'Radarr' });
|
logger.info('Radarr accepted request', { label: 'Radarr' });
|
||||||
logger.debug('Radarr add details', {
|
logger.debug('Radarr add details', {
|
||||||
label: 'Radarr',
|
label: 'Radarr',
|
||||||
movie: data,
|
movie: response.data,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.error('Failed to add movie to Radarr', {
|
logger.error('Failed to add movie to Radarr', {
|
||||||
@@ -177,22 +212,15 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
});
|
});
|
||||||
throw new Error('Failed to add movie to Radarr');
|
throw new Error('Failed to add movie to Radarr');
|
||||||
}
|
}
|
||||||
return data;
|
return response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error(
|
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.',
|
'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.',
|
||||||
{
|
{
|
||||||
label: 'Radarr',
|
label: 'Radarr',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
options,
|
options,
|
||||||
response: errorData,
|
response: e?.response?.data,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
throw new Error('Failed to add movie to Radarr');
|
throw new Error('Failed to add movie to Radarr');
|
||||||
@@ -221,9 +249,11 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
public removeMovie = async (movieId: number): Promise<void> => {
|
public removeMovie = async (movieId: number): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { id, title } = await this.getMovieByTmdbId(movieId);
|
const { id, title } = await this.getMovieByTmdbId(movieId);
|
||||||
await this.delete(`/movie/${id}`, {
|
await this.axios.delete(`/movie/${id}`, {
|
||||||
deleteFiles: 'true',
|
params: {
|
||||||
addImportExclusion: 'false',
|
deleteFiles: true,
|
||||||
|
addImportExclusion: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
logger.info(`[Radarr] Removed movie ${title}`);
|
logger.info(`[Radarr] Removed movie ${title}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -117,9 +117,9 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
|
|
||||||
public async getSeries(): Promise<SonarrSeries[]> {
|
public async getSeries(): Promise<SonarrSeries[]> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<SonarrSeries[]>('/series');
|
const response = await this.axios.get<SonarrSeries[]>('/series');
|
||||||
|
|
||||||
return data;
|
return response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[Sonarr] Failed to retrieve series: ${e.message}`);
|
throw new Error(`[Sonarr] Failed to retrieve series: ${e.message}`);
|
||||||
}
|
}
|
||||||
@@ -127,9 +127,9 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
|
|
||||||
public async getSeriesById(id: number): Promise<SonarrSeries> {
|
public async getSeriesById(id: number): Promise<SonarrSeries> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<SonarrSeries>(`/series/${id}`);
|
const response = await this.axios.get<SonarrSeries>(`/series/${id}`);
|
||||||
|
|
||||||
return data;
|
return response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[Sonarr] Failed to retrieve series by ID: ${e.message}`);
|
throw new Error(`[Sonarr] Failed to retrieve series by ID: ${e.message}`);
|
||||||
}
|
}
|
||||||
@@ -137,15 +137,17 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
|
|
||||||
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
|
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<SonarrSeries[]>('/series/lookup', {
|
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
|
||||||
term: title,
|
params: {
|
||||||
|
term: title,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data[0]) {
|
if (!response.data[0]) {
|
||||||
throw new Error('No series found');
|
throw new Error('No series found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error retrieving series by series title', {
|
logger.error('Error retrieving series by series title', {
|
||||||
label: 'Sonarr API',
|
label: 'Sonarr API',
|
||||||
@@ -158,15 +160,17 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
|
|
||||||
public async getSeriesByTvdbId(id: number): Promise<SonarrSeries> {
|
public async getSeriesByTvdbId(id: number): Promise<SonarrSeries> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<SonarrSeries[]>('/series/lookup', {
|
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
|
||||||
term: `tvdb:${id}`,
|
params: {
|
||||||
|
term: `tvdb:${id}`,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data[0]) {
|
if (!response.data[0]) {
|
||||||
throw new Error('Series not found');
|
throw new Error('Series not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return data[0];
|
return response.data[0];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error retrieving series by tvdb ID', {
|
logger.error('Error retrieving series by tvdb ID', {
|
||||||
label: 'Sonarr API',
|
label: 'Sonarr API',
|
||||||
@@ -184,30 +188,32 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
// If the series already exists, we will simply just update it
|
// If the series already exists, we will simply just update it
|
||||||
if (series.id) {
|
if (series.id) {
|
||||||
series.monitored = options.monitored ?? series.monitored;
|
series.monitored = options.monitored ?? series.monitored;
|
||||||
series.tags = options.tags ?? series.tags;
|
series.tags = options.tags
|
||||||
|
? Array.from(new Set([...series.tags, ...options.tags]))
|
||||||
|
: series.tags;
|
||||||
series.seasons = this.buildSeasonList(options.seasons, series.seasons);
|
series.seasons = this.buildSeasonList(options.seasons, series.seasons);
|
||||||
|
|
||||||
const newSeriesData = await this.put<SonarrSeries>(
|
const newSeriesResponse = await this.axios.put<SonarrSeries>(
|
||||||
'/series',
|
'/series',
|
||||||
series as any
|
series
|
||||||
);
|
);
|
||||||
|
|
||||||
if (newSeriesData.id) {
|
if (newSeriesResponse.data.id) {
|
||||||
logger.info('Updated existing series in Sonarr.', {
|
logger.info('Updated existing series in Sonarr.', {
|
||||||
label: 'Sonarr',
|
label: 'Sonarr',
|
||||||
seriesId: newSeriesData.id,
|
seriesId: newSeriesResponse.data.id,
|
||||||
seriesTitle: newSeriesData.title,
|
seriesTitle: newSeriesResponse.data.title,
|
||||||
});
|
});
|
||||||
logger.debug('Sonarr update details', {
|
logger.debug('Sonarr update details', {
|
||||||
label: 'Sonarr',
|
label: 'Sonarr',
|
||||||
movie: newSeriesData,
|
series: newSeriesResponse.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (options.searchNow) {
|
if (options.searchNow) {
|
||||||
this.searchSeries(newSeriesData.id);
|
this.searchSeries(newSeriesResponse.data.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return newSeriesData;
|
return newSeriesResponse.data;
|
||||||
} else {
|
} else {
|
||||||
logger.error('Failed to update series in Sonarr', {
|
logger.error('Failed to update series in Sonarr', {
|
||||||
label: 'Sonarr',
|
label: 'Sonarr',
|
||||||
@@ -217,35 +223,38 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdSeriesData = await this.post<SonarrSeries>('/series', {
|
const createdSeriesResponse = await this.axios.post<SonarrSeries>(
|
||||||
tvdbId: options.tvdbid,
|
'/series',
|
||||||
title: options.title,
|
{
|
||||||
qualityProfileId: options.profileId,
|
tvdbId: options.tvdbid,
|
||||||
languageProfileId: options.languageProfileId,
|
title: options.title,
|
||||||
seasons: this.buildSeasonList(
|
qualityProfileId: options.profileId,
|
||||||
options.seasons,
|
languageProfileId: options.languageProfileId,
|
||||||
series.seasons.map((season) => ({
|
seasons: this.buildSeasonList(
|
||||||
seasonNumber: season.seasonNumber,
|
options.seasons,
|
||||||
// We force all seasons to false if its the first request
|
series.seasons.map((season) => ({
|
||||||
monitored: false,
|
seasonNumber: season.seasonNumber,
|
||||||
}))
|
// We force all seasons to false if its the first request
|
||||||
),
|
monitored: false,
|
||||||
tags: options.tags,
|
}))
|
||||||
seasonFolder: options.seasonFolder,
|
),
|
||||||
monitored: options.monitored,
|
tags: options.tags,
|
||||||
rootFolderPath: options.rootFolderPath,
|
seasonFolder: options.seasonFolder,
|
||||||
seriesType: options.seriesType,
|
monitored: options.monitored,
|
||||||
addOptions: {
|
rootFolderPath: options.rootFolderPath,
|
||||||
ignoreEpisodesWithFiles: true,
|
seriesType: options.seriesType,
|
||||||
searchForMissingEpisodes: options.searchNow,
|
addOptions: {
|
||||||
},
|
ignoreEpisodesWithFiles: true,
|
||||||
} as Partial<SonarrSeries>);
|
searchForMissingEpisodes: options.searchNow,
|
||||||
|
},
|
||||||
|
} as Partial<SonarrSeries>
|
||||||
|
);
|
||||||
|
|
||||||
if (createdSeriesData.id) {
|
if (createdSeriesResponse.data.id) {
|
||||||
logger.info('Sonarr accepted request', { label: 'Sonarr' });
|
logger.info('Sonarr accepted request', { label: 'Sonarr' });
|
||||||
logger.debug('Sonarr add details', {
|
logger.debug('Sonarr add details', {
|
||||||
label: 'Sonarr',
|
label: 'Sonarr',
|
||||||
movie: createdSeriesData,
|
series: createdSeriesResponse.data,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.error('Failed to add movie to Sonarr', {
|
logger.error('Failed to add movie to Sonarr', {
|
||||||
@@ -255,20 +264,13 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
throw new Error('Failed to add series to Sonarr');
|
throw new Error('Failed to add series to Sonarr');
|
||||||
}
|
}
|
||||||
|
|
||||||
return createdSeriesData;
|
return createdSeriesResponse.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Something went wrong while adding a series to Sonarr.', {
|
logger.error('Something went wrong while adding a series to Sonarr.', {
|
||||||
label: 'Sonarr API',
|
label: 'Sonarr API',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
options,
|
options,
|
||||||
response: errorData,
|
response: e?.response?.data,
|
||||||
});
|
});
|
||||||
throw new Error('Failed to add series');
|
throw new Error('Failed to add series');
|
||||||
}
|
}
|
||||||
@@ -340,13 +342,14 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
|
|
||||||
return newSeasons;
|
return newSeasons;
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeSerie = async (serieId: number): Promise<void> => {
|
public removeSerie = async (serieId: number): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { id, title } = await this.getSeriesByTvdbId(serieId);
|
const { id, title } = await this.getSeriesByTvdbId(serieId);
|
||||||
await this.delete(`/series/${id}`, {
|
await this.axios.delete(`/series/${id}`, {
|
||||||
deleteFiles: 'true',
|
params: {
|
||||||
addImportExclusion: 'false',
|
deleteFiles: true,
|
||||||
|
addImportExclusion: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
logger.info(`[Radarr] Removed serie ${title}`);
|
logger.info(`[Radarr] Removed serie ${title}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import ExternalAPI from '@server/api/externalapi';
|
|
||||||
import type { User } from '@server/entity/User';
|
import type { User } from '@server/entity/User';
|
||||||
import type { TautulliSettings } from '@server/lib/settings';
|
import type { TautulliSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import { requestInterceptorFunction } from '@server/utils/customProxyAgent';
|
||||||
|
import type { AxiosInstance } from 'axios';
|
||||||
|
import axios from 'axios';
|
||||||
import { uniqWith } from 'lodash';
|
import { uniqWith } from 'lodash';
|
||||||
|
|
||||||
export interface TautulliHistoryRecord {
|
export interface TautulliHistoryRecord {
|
||||||
@@ -112,25 +114,26 @@ interface TautulliInfoResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class TautulliAPI extends ExternalAPI {
|
class TautulliAPI {
|
||||||
|
private axios: AxiosInstance;
|
||||||
|
|
||||||
constructor(settings: TautulliSettings) {
|
constructor(settings: TautulliSettings) {
|
||||||
super(
|
this.axios = axios.create({
|
||||||
`${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${
|
baseURL: `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${
|
||||||
settings.port
|
settings.port
|
||||||
}${settings.urlBase ?? ''}`,
|
}${settings.urlBase ?? ''}`,
|
||||||
{
|
params: { apikey: settings.apiKey },
|
||||||
apikey: settings.apiKey || '',
|
});
|
||||||
}
|
this.axios.interceptors.request.use(requestInterceptorFunction);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getInfo(): Promise<TautulliInfo> {
|
public async getInfo(): Promise<TautulliInfo> {
|
||||||
try {
|
try {
|
||||||
return (
|
return (
|
||||||
await this.get<TautulliInfoResponse>('/api/v2', {
|
await this.axios.get<TautulliInfoResponse>('/api/v2', {
|
||||||
cmd: 'get_tautulli_info',
|
params: { cmd: 'get_tautulli_info' },
|
||||||
})
|
})
|
||||||
).response.data;
|
).data.response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Something went wrong fetching Tautulli server info', {
|
logger.error('Something went wrong fetching Tautulli server info', {
|
||||||
label: 'Tautulli API',
|
label: 'Tautulli API',
|
||||||
@@ -147,12 +150,14 @@ class TautulliAPI extends ExternalAPI {
|
|||||||
): Promise<TautulliWatchStats[]> {
|
): Promise<TautulliWatchStats[]> {
|
||||||
try {
|
try {
|
||||||
return (
|
return (
|
||||||
await this.get<TautulliWatchStatsResponse>('/api/v2', {
|
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', {
|
||||||
cmd: 'get_item_watch_time_stats',
|
params: {
|
||||||
rating_key: ratingKey,
|
cmd: 'get_item_watch_time_stats',
|
||||||
grouping: '1',
|
rating_key: ratingKey,
|
||||||
|
grouping: 1,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
).response.data;
|
).data.response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong fetching media watch stats from Tautulli',
|
'Something went wrong fetching media watch stats from Tautulli',
|
||||||
@@ -173,12 +178,14 @@ class TautulliAPI extends ExternalAPI {
|
|||||||
): Promise<TautulliWatchUser[]> {
|
): Promise<TautulliWatchUser[]> {
|
||||||
try {
|
try {
|
||||||
return (
|
return (
|
||||||
await this.get<TautulliWatchUsersResponse>('/api/v2', {
|
await this.axios.get<TautulliWatchUsersResponse>('/api/v2', {
|
||||||
cmd: 'get_item_user_stats',
|
params: {
|
||||||
rating_key: ratingKey,
|
cmd: 'get_item_user_stats',
|
||||||
grouping: '1',
|
rating_key: ratingKey,
|
||||||
|
grouping: 1,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
).response.data;
|
).data.response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong fetching media watch users from Tautulli',
|
'Something went wrong fetching media watch users from Tautulli',
|
||||||
@@ -201,13 +208,15 @@ class TautulliAPI extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
await this.get<TautulliWatchStatsResponse>('/api/v2', {
|
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', {
|
||||||
cmd: 'get_user_watch_time_stats',
|
params: {
|
||||||
user_id: user.plexId.toString(),
|
cmd: 'get_user_watch_time_stats',
|
||||||
query_days: '0',
|
user_id: user.plexId,
|
||||||
grouping: '1',
|
query_days: 0,
|
||||||
|
grouping: 1,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
).response.data[0];
|
).data.response.data[0];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong fetching user watch stats from Tautulli',
|
'Something went wrong fetching user watch stats from Tautulli',
|
||||||
@@ -238,17 +247,19 @@ class TautulliAPI extends ExternalAPI {
|
|||||||
|
|
||||||
while (results.length < 20) {
|
while (results.length < 20) {
|
||||||
const tautulliData = (
|
const tautulliData = (
|
||||||
await this.get<TautulliHistoryResponse>('/api/v2', {
|
await this.axios.get<TautulliHistoryResponse>('/api/v2', {
|
||||||
cmd: 'get_history',
|
params: {
|
||||||
grouping: '1',
|
cmd: 'get_history',
|
||||||
order_column: 'date',
|
grouping: 1,
|
||||||
order_dir: 'desc',
|
order_column: 'date',
|
||||||
user_id: user.plexId.toString(),
|
order_dir: 'desc',
|
||||||
media_type: 'movie,episode',
|
user_id: user.plexId,
|
||||||
length: take.toString(),
|
media_type: 'movie,episode',
|
||||||
start: start.toString(),
|
length: take,
|
||||||
|
start,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
).response.data.data;
|
).data.response.data.data;
|
||||||
|
|
||||||
if (!tautulliData.length) {
|
if (!tautulliData.length) {
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import ExternalAPI from '@server/api/externalapi';
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
|
import type { TvShowProvider } from '@server/api/provider';
|
||||||
import cacheManager from '@server/lib/cache';
|
import cacheManager from '@server/lib/cache';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
import type {
|
import type {
|
||||||
TmdbCollection,
|
TmdbCollection,
|
||||||
@@ -37,23 +39,36 @@ interface SingleSearchOptions extends SearchOptions {
|
|||||||
year?: number;
|
year?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SortOptions =
|
export const SortOptionsIterable = [
|
||||||
| 'popularity.asc'
|
'popularity.desc',
|
||||||
| 'popularity.desc'
|
'popularity.asc',
|
||||||
| 'release_date.asc'
|
'release_date.desc',
|
||||||
| 'release_date.desc'
|
'release_date.asc',
|
||||||
| 'revenue.asc'
|
'revenue.desc',
|
||||||
| 'revenue.desc'
|
'revenue.asc',
|
||||||
| 'primary_release_date.asc'
|
'primary_release_date.desc',
|
||||||
| 'primary_release_date.desc'
|
'primary_release_date.asc',
|
||||||
| 'original_title.asc'
|
'original_title.asc',
|
||||||
| 'original_title.desc'
|
'original_title.desc',
|
||||||
| 'vote_average.asc'
|
'vote_average.desc',
|
||||||
| 'vote_average.desc'
|
'vote_average.asc',
|
||||||
| 'vote_count.asc'
|
'vote_count.desc',
|
||||||
| 'vote_count.desc'
|
'vote_count.asc',
|
||||||
| 'first_air_date.asc'
|
'first_air_date.desc',
|
||||||
| 'first_air_date.desc';
|
'first_air_date.asc',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type SortOptions = (typeof SortOptionsIterable)[number];
|
||||||
|
|
||||||
|
export interface TmdbCertificationResponse {
|
||||||
|
certifications: {
|
||||||
|
[country: string]: {
|
||||||
|
certification: string;
|
||||||
|
meaning?: string;
|
||||||
|
order?: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface DiscoverMovieOptions {
|
interface DiscoverMovieOptions {
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -74,6 +89,10 @@ interface DiscoverMovieOptions {
|
|||||||
sortBy?: SortOptions;
|
sortBy?: SortOptions;
|
||||||
watchRegion?: string;
|
watchRegion?: string;
|
||||||
watchProviders?: string;
|
watchProviders?: string;
|
||||||
|
certification?: string;
|
||||||
|
certificationGte?: string;
|
||||||
|
certificationLte?: string;
|
||||||
|
certificationCountry?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DiscoverTvOptions {
|
interface DiscoverTvOptions {
|
||||||
@@ -96,9 +115,14 @@ interface DiscoverTvOptions {
|
|||||||
watchRegion?: string;
|
watchRegion?: string;
|
||||||
watchProviders?: string;
|
watchProviders?: string;
|
||||||
withStatus?: string; // Returning Series: 0 Planned: 1 In Production: 2 Ended: 3 Cancelled: 4 Pilot: 5
|
withStatus?: string; // Returning Series: 0 Planned: 1 In Production: 2 Ended: 3 Cancelled: 4 Pilot: 5
|
||||||
|
certification?: string;
|
||||||
|
certificationGte?: string;
|
||||||
|
certificationLte?: string;
|
||||||
|
certificationCountry?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class TheMovieDb extends ExternalAPI {
|
class TheMovieDb extends ExternalAPI implements TvShowProvider {
|
||||||
|
private locale: string;
|
||||||
private discoverRegion?: string;
|
private discoverRegion?: string;
|
||||||
private originalLanguage?: string;
|
private originalLanguage?: string;
|
||||||
constructor({
|
constructor({
|
||||||
@@ -108,16 +132,17 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
super(
|
super(
|
||||||
'https://api.themoviedb.org/3',
|
'https://api.themoviedb.org/3',
|
||||||
{
|
{
|
||||||
api_key: 'db55323b8d3e4154498498a75642b381',
|
api_key: '431a8708161bcd1f1fbe7536137e61ed',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
nodeCache: cacheManager.getCache('tmdb').data,
|
nodeCache: cacheManager.getCache('tmdb').data,
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
|
maxRequests: 20,
|
||||||
maxRPS: 50,
|
maxRPS: 50,
|
||||||
id: 'tmdb',
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
this.locale = getSettings().main?.locale || 'en';
|
||||||
this.discoverRegion = discoverRegion;
|
this.discoverRegion = discoverRegion;
|
||||||
this.originalLanguage = originalLanguage;
|
this.originalLanguage = originalLanguage;
|
||||||
}
|
}
|
||||||
@@ -126,14 +151,11 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
query,
|
query,
|
||||||
page = 1,
|
page = 1,
|
||||||
includeAdult = false,
|
includeAdult = false,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: SearchOptions): Promise<TmdbSearchMultiResponse> => {
|
}: SearchOptions): Promise<TmdbSearchMultiResponse> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<TmdbSearchMultiResponse>('/search/multi', {
|
const data = await this.get<TmdbSearchMultiResponse>('/search/multi', {
|
||||||
query,
|
params: { query, page, include_adult: includeAdult, language },
|
||||||
page: page.toString(),
|
|
||||||
include_adult: includeAdult ? 'true' : 'false',
|
|
||||||
language,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -151,16 +173,18 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
query,
|
query,
|
||||||
page = 1,
|
page = 1,
|
||||||
includeAdult = false,
|
includeAdult = false,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
year,
|
year,
|
||||||
}: SingleSearchOptions): Promise<TmdbSearchMovieResponse> => {
|
}: SingleSearchOptions): Promise<TmdbSearchMovieResponse> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<TmdbSearchMovieResponse>('/search/movie', {
|
const data = await this.get<TmdbSearchMovieResponse>('/search/movie', {
|
||||||
query,
|
params: {
|
||||||
page: page.toString(),
|
query,
|
||||||
include_adult: includeAdult ? 'true' : 'false',
|
page,
|
||||||
language,
|
include_adult: includeAdult,
|
||||||
primary_release_year: year?.toString() || '',
|
language,
|
||||||
|
primary_release_year: year,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -178,16 +202,18 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
query,
|
query,
|
||||||
page = 1,
|
page = 1,
|
||||||
includeAdult = false,
|
includeAdult = false,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
year,
|
year,
|
||||||
}: SingleSearchOptions): Promise<TmdbSearchTvResponse> => {
|
}: SingleSearchOptions): Promise<TmdbSearchTvResponse> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<TmdbSearchTvResponse>('/search/tv', {
|
const data = await this.get<TmdbSearchTvResponse>('/search/tv', {
|
||||||
query,
|
params: {
|
||||||
page: page.toString(),
|
query,
|
||||||
include_adult: includeAdult ? 'true' : 'false',
|
page,
|
||||||
language,
|
include_adult: includeAdult,
|
||||||
first_air_date_year: year?.toString() || '',
|
language,
|
||||||
|
first_air_date_year: year,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -203,14 +229,14 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
public getPerson = async ({
|
public getPerson = async ({
|
||||||
personId,
|
personId,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
personId: number;
|
personId: number;
|
||||||
language?: string;
|
language?: string;
|
||||||
}): Promise<TmdbPersonDetails> => {
|
}): Promise<TmdbPersonDetails> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<TmdbPersonDetails>(`/person/${personId}`, {
|
const data = await this.get<TmdbPersonDetails>(`/person/${personId}`, {
|
||||||
language,
|
params: { language },
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -221,7 +247,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
public getPersonCombinedCredits = async ({
|
public getPersonCombinedCredits = async ({
|
||||||
personId,
|
personId,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
personId: number;
|
personId: number;
|
||||||
language?: string;
|
language?: string;
|
||||||
@@ -230,7 +256,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbPersonCombinedCredits>(
|
const data = await this.get<TmdbPersonCombinedCredits>(
|
||||||
`/person/${personId}/combined_credits`,
|
`/person/${personId}/combined_credits`,
|
||||||
{
|
{
|
||||||
language,
|
params: { language },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -244,7 +270,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
public getMovie = async ({
|
public getMovie = async ({
|
||||||
movieId,
|
movieId,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
movieId: number;
|
movieId: number;
|
||||||
language?: string;
|
language?: string;
|
||||||
@@ -253,9 +279,12 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbMovieDetails>(
|
const data = await this.get<TmdbMovieDetails>(
|
||||||
`/movie/${movieId}`,
|
`/movie/${movieId}`,
|
||||||
{
|
{
|
||||||
language,
|
params: {
|
||||||
append_to_response:
|
language,
|
||||||
'credits,external_ids,videos,keywords,release_dates,watch/providers',
|
append_to_response:
|
||||||
|
'credits,external_ids,videos,keywords,release_dates,watch/providers',
|
||||||
|
include_video_language: language + ', en',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
43200
|
43200
|
||||||
);
|
);
|
||||||
@@ -268,7 +297,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
public getTvShow = async ({
|
public getTvShow = async ({
|
||||||
tvId,
|
tvId,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
tvId: number;
|
tvId: number;
|
||||||
language?: string;
|
language?: string;
|
||||||
@@ -277,9 +306,12 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbTvDetails>(
|
const data = await this.get<TmdbTvDetails>(
|
||||||
`/tv/${tvId}`,
|
`/tv/${tvId}`,
|
||||||
{
|
{
|
||||||
language,
|
params: {
|
||||||
append_to_response:
|
language,
|
||||||
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
|
append_to_response:
|
||||||
|
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
|
||||||
|
include_video_language: language + ', en',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
43200
|
43200
|
||||||
);
|
);
|
||||||
@@ -303,11 +335,20 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSeasonWithEpisodes>(
|
const data = await this.get<TmdbSeasonWithEpisodes>(
|
||||||
`/tv/${tvId}/season/${seasonNumber}`,
|
`/tv/${tvId}/season/${seasonNumber}`,
|
||||||
{
|
{
|
||||||
language: language || '',
|
params: {
|
||||||
append_to_response: 'external_ids',
|
language,
|
||||||
|
append_to_response: 'external_ids',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
data.episodes = data.episodes.map((episode) => {
|
||||||
|
if (episode.still_path) {
|
||||||
|
episode.still_path = `https://image.tmdb.org/t/p/original/${episode.still_path}`;
|
||||||
|
}
|
||||||
|
return episode;
|
||||||
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`);
|
||||||
@@ -317,7 +358,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
public async getMovieRecommendations({
|
public async getMovieRecommendations({
|
||||||
movieId,
|
movieId,
|
||||||
page = 1,
|
page = 1,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
movieId: number;
|
movieId: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -327,8 +368,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSearchMovieResponse>(
|
const data = await this.get<TmdbSearchMovieResponse>(
|
||||||
`/movie/${movieId}/recommendations`,
|
`/movie/${movieId}/recommendations`,
|
||||||
{
|
{
|
||||||
page: page.toString(),
|
params: {
|
||||||
language,
|
page,
|
||||||
|
language,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -341,7 +384,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
public async getMovieSimilar({
|
public async getMovieSimilar({
|
||||||
movieId,
|
movieId,
|
||||||
page = 1,
|
page = 1,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
movieId: number;
|
movieId: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -351,8 +394,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSearchMovieResponse>(
|
const data = await this.get<TmdbSearchMovieResponse>(
|
||||||
`/movie/${movieId}/similar`,
|
`/movie/${movieId}/similar`,
|
||||||
{
|
{
|
||||||
page: page.toString(),
|
params: {
|
||||||
language,
|
page,
|
||||||
|
language,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -365,7 +410,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
public async getMoviesByKeyword({
|
public async getMoviesByKeyword({
|
||||||
keywordId,
|
keywordId,
|
||||||
page = 1,
|
page = 1,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
keywordId: number;
|
keywordId: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -375,8 +420,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSearchMovieResponse>(
|
const data = await this.get<TmdbSearchMovieResponse>(
|
||||||
`/keyword/${keywordId}/movies`,
|
`/keyword/${keywordId}/movies`,
|
||||||
{
|
{
|
||||||
page: page.toString(),
|
params: {
|
||||||
language,
|
page,
|
||||||
|
language,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -389,7 +436,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
public async getTvRecommendations({
|
public async getTvRecommendations({
|
||||||
tvId,
|
tvId,
|
||||||
page = 1,
|
page = 1,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
tvId: number;
|
tvId: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -399,8 +446,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSearchTvResponse>(
|
const data = await this.get<TmdbSearchTvResponse>(
|
||||||
`/tv/${tvId}/recommendations`,
|
`/tv/${tvId}/recommendations`,
|
||||||
{
|
{
|
||||||
page: page.toString(),
|
params: {
|
||||||
language,
|
page,
|
||||||
|
language,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -415,7 +464,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
public async getTvSimilar({
|
public async getTvSimilar({
|
||||||
tvId,
|
tvId,
|
||||||
page = 1,
|
page = 1,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
tvId: number;
|
tvId: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -423,8 +472,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
}): Promise<TmdbSearchTvResponse> {
|
}): Promise<TmdbSearchTvResponse> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<TmdbSearchTvResponse>(`/tv/${tvId}/similar`, {
|
const data = await this.get<TmdbSearchTvResponse>(`/tv/${tvId}/similar`, {
|
||||||
page: page.toString(),
|
params: {
|
||||||
language,
|
page,
|
||||||
|
language,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -437,7 +488,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
sortBy = 'popularity.desc',
|
sortBy = 'popularity.desc',
|
||||||
page = 1,
|
page = 1,
|
||||||
includeAdult = false,
|
includeAdult = false,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
primaryReleaseDateGte,
|
primaryReleaseDateGte,
|
||||||
primaryReleaseDateLte,
|
primaryReleaseDateLte,
|
||||||
originalLanguage,
|
originalLanguage,
|
||||||
@@ -452,6 +503,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
voteCountLte,
|
voteCountLte,
|
||||||
watchProviders,
|
watchProviders,
|
||||||
watchRegion,
|
watchRegion,
|
||||||
|
certification,
|
||||||
|
certificationGte,
|
||||||
|
certificationLte,
|
||||||
|
certificationCountry,
|
||||||
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
||||||
try {
|
try {
|
||||||
const defaultFutureDate = new Date(
|
const defaultFutureDate = new Date(
|
||||||
@@ -465,38 +520,44 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
.split('T')[0];
|
.split('T')[0];
|
||||||
|
|
||||||
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
|
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
|
||||||
sort_by: sortBy,
|
params: {
|
||||||
page: page.toString(),
|
sort_by: sortBy,
|
||||||
include_adult: includeAdult ? 'true' : 'false',
|
page,
|
||||||
language,
|
include_adult: includeAdult,
|
||||||
region: this.discoverRegion || '',
|
language,
|
||||||
with_original_language:
|
region: this.discoverRegion || '',
|
||||||
originalLanguage && originalLanguage !== 'all'
|
with_original_language:
|
||||||
? originalLanguage
|
originalLanguage && originalLanguage !== 'all'
|
||||||
: originalLanguage === 'all'
|
? originalLanguage
|
||||||
? ''
|
: originalLanguage === 'all'
|
||||||
: this.originalLanguage || '',
|
? undefined
|
||||||
// Set our release date values, but check if one is set and not the other,
|
: this.originalLanguage,
|
||||||
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
// Set our release date values, but check if one is set and not the other,
|
||||||
'primary_release_date.gte':
|
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
||||||
!primaryReleaseDateGte && primaryReleaseDateLte
|
'primary_release_date.gte':
|
||||||
? defaultPastDate
|
!primaryReleaseDateGte && primaryReleaseDateLte
|
||||||
: primaryReleaseDateGte || '',
|
? defaultPastDate
|
||||||
'primary_release_date.lte':
|
: primaryReleaseDateGte,
|
||||||
!primaryReleaseDateLte && primaryReleaseDateGte
|
'primary_release_date.lte':
|
||||||
? defaultFutureDate
|
!primaryReleaseDateLte && primaryReleaseDateGte
|
||||||
: primaryReleaseDateLte || '',
|
? defaultFutureDate
|
||||||
with_genres: genre || '',
|
: primaryReleaseDateLte,
|
||||||
with_companies: studio || '',
|
with_genres: genre,
|
||||||
with_keywords: keywords || '',
|
with_companies: studio,
|
||||||
'with_runtime.gte': withRuntimeGte || '',
|
with_keywords: keywords,
|
||||||
'with_runtime.lte': withRuntimeLte || '',
|
'with_runtime.gte': withRuntimeGte,
|
||||||
'vote_average.gte': voteAverageGte || '',
|
'with_runtime.lte': withRuntimeLte,
|
||||||
'vote_average.lte': voteAverageLte || '',
|
'vote_average.gte': voteAverageGte,
|
||||||
'vote_count.gte': voteCountGte || '',
|
'vote_average.lte': voteAverageLte,
|
||||||
'vote_count.lte': voteCountLte || '',
|
'vote_count.gte': voteCountGte,
|
||||||
watch_region: watchRegion || '',
|
'vote_count.lte': voteCountLte,
|
||||||
with_watch_providers: watchProviders || '',
|
watch_region: watchRegion,
|
||||||
|
with_watch_providers: watchProviders,
|
||||||
|
certification: certification,
|
||||||
|
'certification.gte': certificationGte,
|
||||||
|
'certification.lte': certificationLte,
|
||||||
|
certification_country: certificationCountry,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -508,7 +569,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
public getDiscoverTv = async ({
|
public getDiscoverTv = async ({
|
||||||
sortBy = 'popularity.desc',
|
sortBy = 'popularity.desc',
|
||||||
page = 1,
|
page = 1,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
firstAirDateGte,
|
firstAirDateGte,
|
||||||
firstAirDateLte,
|
firstAirDateLte,
|
||||||
includeEmptyReleaseDate = false,
|
includeEmptyReleaseDate = false,
|
||||||
@@ -525,6 +586,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
watchProviders,
|
watchProviders,
|
||||||
watchRegion,
|
watchRegion,
|
||||||
withStatus,
|
withStatus,
|
||||||
|
certification,
|
||||||
|
certificationGte,
|
||||||
|
certificationLte,
|
||||||
|
certificationCountry,
|
||||||
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
||||||
try {
|
try {
|
||||||
const defaultFutureDate = new Date(
|
const defaultFutureDate = new Date(
|
||||||
@@ -538,41 +603,45 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
.split('T')[0];
|
.split('T')[0];
|
||||||
|
|
||||||
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
|
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
|
||||||
sort_by: sortBy,
|
params: {
|
||||||
page: page.toString(),
|
sort_by: sortBy,
|
||||||
language,
|
page,
|
||||||
region: this.discoverRegion || '',
|
language,
|
||||||
// Set our release date values, but check if one is set and not the other,
|
region: this.discoverRegion || '',
|
||||||
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
// Set our release date values, but check if one is set and not the other,
|
||||||
'first_air_date.gte':
|
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
||||||
!firstAirDateGte && firstAirDateLte
|
'first_air_date.gte':
|
||||||
? defaultPastDate
|
!firstAirDateGte && firstAirDateLte
|
||||||
: firstAirDateGte || '',
|
? defaultPastDate
|
||||||
'first_air_date.lte':
|
: firstAirDateGte,
|
||||||
!firstAirDateLte && firstAirDateGte
|
'first_air_date.lte':
|
||||||
? defaultFutureDate
|
!firstAirDateLte && firstAirDateGte
|
||||||
: firstAirDateLte || '',
|
? defaultFutureDate
|
||||||
with_original_language:
|
: firstAirDateLte,
|
||||||
originalLanguage && originalLanguage !== 'all'
|
with_original_language:
|
||||||
? originalLanguage
|
originalLanguage && originalLanguage !== 'all'
|
||||||
: originalLanguage === 'all'
|
? originalLanguage
|
||||||
? ''
|
: originalLanguage === 'all'
|
||||||
: this.originalLanguage || '',
|
? undefined
|
||||||
include_null_first_air_dates: includeEmptyReleaseDate
|
: this.originalLanguage,
|
||||||
? 'true'
|
include_null_first_air_dates: includeEmptyReleaseDate,
|
||||||
: 'false',
|
with_genres: genre,
|
||||||
with_genres: genre || '',
|
with_networks: network,
|
||||||
with_networks: network?.toString() || '',
|
with_keywords: keywords,
|
||||||
with_keywords: keywords || '',
|
'with_runtime.gte': withRuntimeGte,
|
||||||
'with_runtime.gte': withRuntimeGte || '',
|
'with_runtime.lte': withRuntimeLte,
|
||||||
'with_runtime.lte': withRuntimeLte || '',
|
'vote_average.gte': voteAverageGte,
|
||||||
'vote_average.gte': voteAverageGte || '',
|
'vote_average.lte': voteAverageLte,
|
||||||
'vote_average.lte': voteAverageLte || '',
|
'vote_count.gte': voteCountGte,
|
||||||
'vote_count.gte': voteCountGte || '',
|
'vote_count.lte': voteCountLte,
|
||||||
'vote_count.lte': voteCountLte || '',
|
with_watch_providers: watchProviders,
|
||||||
with_watch_providers: watchProviders || '',
|
watch_region: watchRegion,
|
||||||
watch_region: watchRegion || '',
|
with_status: withStatus,
|
||||||
with_status: withStatus || '',
|
certification: certification,
|
||||||
|
'certification.gte': certificationGte,
|
||||||
|
'certification.lte': certificationLte,
|
||||||
|
certification_country: certificationCountry,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -583,7 +652,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
public getUpcomingMovies = async ({
|
public getUpcomingMovies = async ({
|
||||||
page = 1,
|
page = 1,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
page: number;
|
page: number;
|
||||||
language: string;
|
language: string;
|
||||||
@@ -592,10 +661,12 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbUpcomingMoviesResponse>(
|
const data = await this.get<TmdbUpcomingMoviesResponse>(
|
||||||
'/movie/upcoming',
|
'/movie/upcoming',
|
||||||
{
|
{
|
||||||
page: page.toString(),
|
params: {
|
||||||
language,
|
page,
|
||||||
region: this.discoverRegion || '',
|
language,
|
||||||
originalLanguage: this.originalLanguage || '',
|
region: this.discoverRegion,
|
||||||
|
originalLanguage: this.originalLanguage,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -608,7 +679,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
public getAllTrending = async ({
|
public getAllTrending = async ({
|
||||||
page = 1,
|
page = 1,
|
||||||
timeWindow = 'day',
|
timeWindow = 'day',
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
page?: number;
|
page?: number;
|
||||||
timeWindow?: 'day' | 'week';
|
timeWindow?: 'day' | 'week';
|
||||||
@@ -618,9 +689,11 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSearchMultiResponse>(
|
const data = await this.get<TmdbSearchMultiResponse>(
|
||||||
`/trending/all/${timeWindow}`,
|
`/trending/all/${timeWindow}`,
|
||||||
{
|
{
|
||||||
page: page.toString(),
|
params: {
|
||||||
language,
|
page,
|
||||||
region: this.discoverRegion || '',
|
language,
|
||||||
|
region: this.discoverRegion,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -641,7 +714,9 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSearchMovieResponse>(
|
const data = await this.get<TmdbSearchMovieResponse>(
|
||||||
`/trending/movie/${timeWindow}`,
|
`/trending/movie/${timeWindow}`,
|
||||||
{
|
{
|
||||||
page: page.toString(),
|
params: {
|
||||||
|
page,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -662,7 +737,9 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSearchTvResponse>(
|
const data = await this.get<TmdbSearchTvResponse>(
|
||||||
`/trending/tv/${timeWindow}`,
|
`/trending/tv/${timeWindow}`,
|
||||||
{
|
{
|
||||||
page: page.toString(),
|
params: {
|
||||||
|
page,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -675,7 +752,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
public async getByExternalId({
|
public async getByExternalId({
|
||||||
externalId,
|
externalId,
|
||||||
type,
|
type,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}:
|
}:
|
||||||
| {
|
| {
|
||||||
externalId: string;
|
externalId: string;
|
||||||
@@ -691,8 +768,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbExternalIdResponse>(
|
const data = await this.get<TmdbExternalIdResponse>(
|
||||||
`/find/${externalId}`,
|
`/find/${externalId}`,
|
||||||
{
|
{
|
||||||
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
|
params: {
|
||||||
language,
|
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
|
||||||
|
language,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -704,7 +783,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
public async getMediaByImdbId({
|
public async getMediaByImdbId({
|
||||||
imdbId,
|
imdbId,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
imdbId: string;
|
imdbId: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
@@ -743,7 +822,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
public async getShowByTvdbId({
|
public async getShowByTvdbId({
|
||||||
tvdbId,
|
tvdbId,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
tvdbId: number;
|
tvdbId: number;
|
||||||
language?: string;
|
language?: string;
|
||||||
@@ -773,7 +852,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
public async getCollection({
|
public async getCollection({
|
||||||
collectionId,
|
collectionId,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
collectionId: number;
|
collectionId: number;
|
||||||
language?: string;
|
language?: string;
|
||||||
@@ -782,7 +861,9 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbCollection>(
|
const data = await this.get<TmdbCollection>(
|
||||||
`/collection/${collectionId}`,
|
`/collection/${collectionId}`,
|
||||||
{
|
{
|
||||||
language,
|
params: {
|
||||||
|
language,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -847,7 +928,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getMovieGenres({
|
public async getMovieGenres({
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
language?: string;
|
language?: string;
|
||||||
} = {}): Promise<TmdbGenre[]> {
|
} = {}): Promise<TmdbGenre[]> {
|
||||||
@@ -855,7 +936,9 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbGenresResult>(
|
const data = await this.get<TmdbGenresResult>(
|
||||||
'/genre/movie/list',
|
'/genre/movie/list',
|
||||||
{
|
{
|
||||||
language,
|
params: {
|
||||||
|
language,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -867,7 +950,9 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const englishData = await this.get<TmdbGenresResult>(
|
const englishData = await this.get<TmdbGenresResult>(
|
||||||
'/genre/movie/list',
|
'/genre/movie/list',
|
||||||
{
|
{
|
||||||
language: 'en',
|
params: {
|
||||||
|
language: 'en',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -894,7 +979,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getTvGenres({
|
public async getTvGenres({
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
language?: string;
|
language?: string;
|
||||||
} = {}): Promise<TmdbGenre[]> {
|
} = {}): Promise<TmdbGenre[]> {
|
||||||
@@ -902,7 +987,9 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbGenresResult>(
|
const data = await this.get<TmdbGenresResult>(
|
||||||
'/genre/tv/list',
|
'/genre/tv/list',
|
||||||
{
|
{
|
||||||
language,
|
params: {
|
||||||
|
language,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -914,7 +1001,9 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const englishData = await this.get<TmdbGenresResult>(
|
const englishData = await this.get<TmdbGenresResult>(
|
||||||
'/genre/tv/list',
|
'/genre/tv/list',
|
||||||
{
|
{
|
||||||
language: 'en',
|
params: {
|
||||||
|
language: 'en',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -940,11 +1029,40 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getMovieCertifications =
|
||||||
|
async (): Promise<TmdbCertificationResponse> => {
|
||||||
|
try {
|
||||||
|
const data = await this.get<TmdbCertificationResponse>(
|
||||||
|
'/certification/movie/list',
|
||||||
|
{},
|
||||||
|
604800 // 7 days
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[TMDB] Failed to fetch movie certifications: ${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public getTvCertifications = async (): Promise<TmdbCertificationResponse> => {
|
||||||
|
try {
|
||||||
|
const data = await this.get<TmdbCertificationResponse>(
|
||||||
|
'/certification/tv/list',
|
||||||
|
{},
|
||||||
|
604800 // 7 days
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[TMDB] Failed to fetch TV certifications: ${e.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
public async getKeywordDetails({
|
public async getKeywordDetails({
|
||||||
keywordId,
|
keywordId,
|
||||||
}: {
|
}: {
|
||||||
keywordId: number;
|
keywordId: number;
|
||||||
}): Promise<TmdbKeyword> {
|
}): Promise<TmdbKeyword | null> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<TmdbKeyword>(
|
const data = await this.get<TmdbKeyword>(
|
||||||
`/keyword/${keywordId}`,
|
`/keyword/${keywordId}`,
|
||||||
@@ -954,6 +1072,9 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e.response?.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
throw new Error(`[TMDB] Failed to fetch keyword: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch keyword: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -969,8 +1090,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbKeywordSearchResponse>(
|
const data = await this.get<TmdbKeywordSearchResponse>(
|
||||||
'/search/keyword',
|
'/search/keyword',
|
||||||
{
|
{
|
||||||
query,
|
params: {
|
||||||
page: page.toString(),
|
query,
|
||||||
|
page,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -992,8 +1115,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbCompanySearchResponse>(
|
const data = await this.get<TmdbCompanySearchResponse>(
|
||||||
'/search/company',
|
'/search/company',
|
||||||
{
|
{
|
||||||
query,
|
params: {
|
||||||
page: page.toString(),
|
query,
|
||||||
|
page,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -1013,7 +1138,9 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<{ results: TmdbWatchProviderRegion[] }>(
|
const data = await this.get<{ results: TmdbWatchProviderRegion[] }>(
|
||||||
'/watch/providers/regions',
|
'/watch/providers/regions',
|
||||||
{
|
{
|
||||||
language: language ? this.originalLanguage || '' : '',
|
params: {
|
||||||
|
language: language ?? this.originalLanguage,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -1037,8 +1164,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
||||||
'/watch/providers/movie',
|
'/watch/providers/movie',
|
||||||
{
|
{
|
||||||
language: language ? this.originalLanguage || '' : '',
|
params: {
|
||||||
watch_region: watchRegion,
|
language: language ?? this.originalLanguage,
|
||||||
|
watch_region: watchRegion,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -1062,8 +1191,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
||||||
'/watch/providers/tv',
|
'/watch/providers/tv',
|
||||||
{
|
{
|
||||||
language: language ? this.originalLanguage || '' : '',
|
params: {
|
||||||
watch_region: watchRegion,
|
language: language ?? this.originalLanguage,
|
||||||
|
watch_region: watchRegion,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ export interface TmdbTvEpisodeResult {
|
|||||||
show_id: number;
|
show_id: number;
|
||||||
still_path: string;
|
still_path: string;
|
||||||
vote_average: number;
|
vote_average: number;
|
||||||
vote_cuont: number;
|
vote_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TmdbTvSeasonResult {
|
export interface TmdbTvSeasonResult {
|
||||||
|
|||||||
563
server/api/tvdb/index.ts
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
|
import type { TvShowProvider } from '@server/api/provider';
|
||||||
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
|
import type {
|
||||||
|
TmdbSeasonWithEpisodes,
|
||||||
|
TmdbTvDetails,
|
||||||
|
TmdbTvEpisodeResult,
|
||||||
|
TmdbTvSeasonResult,
|
||||||
|
} from '@server/api/themoviedb/interfaces';
|
||||||
|
import {
|
||||||
|
convertTmdbLanguageToTvdbWithFallback,
|
||||||
|
type TvdbBaseResponse,
|
||||||
|
type TvdbEpisode,
|
||||||
|
type TvdbLoginResponse,
|
||||||
|
type TvdbSeasonDetails,
|
||||||
|
type TvdbTvDetails,
|
||||||
|
} from '@server/api/tvdb/interfaces';
|
||||||
|
import cacheManager, { type AvailableCacheIds } from '@server/lib/cache';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
|
||||||
|
interface TvdbConfig {
|
||||||
|
baseUrl: string;
|
||||||
|
maxRequestsPerSecond: number;
|
||||||
|
maxRequests: number;
|
||||||
|
cachePrefix: AvailableCacheIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: TvdbConfig = {
|
||||||
|
baseUrl: 'https://api4.thetvdb.com/v4',
|
||||||
|
maxRequestsPerSecond: 50,
|
||||||
|
maxRequests: 20,
|
||||||
|
cachePrefix: 'tvdb' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const enum TvdbIdStatus {
|
||||||
|
INVALID = -1,
|
||||||
|
}
|
||||||
|
|
||||||
|
type TvdbId = number;
|
||||||
|
type ValidTvdbId = Exclude<TvdbId, TvdbIdStatus.INVALID>;
|
||||||
|
|
||||||
|
class Tvdb extends ExternalAPI implements TvShowProvider {
|
||||||
|
static instance: Tvdb;
|
||||||
|
private readonly tmdb: TheMovieDb;
|
||||||
|
private static readonly DEFAULT_CACHE_TTL = 43200;
|
||||||
|
private static readonly DEFAULT_LANGUAGE = 'eng';
|
||||||
|
private token: string;
|
||||||
|
private pin?: string;
|
||||||
|
|
||||||
|
constructor(pin?: string) {
|
||||||
|
const finalConfig = { ...DEFAULT_CONFIG };
|
||||||
|
super(
|
||||||
|
finalConfig.baseUrl,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
nodeCache: cacheManager.getCache(finalConfig.cachePrefix).data,
|
||||||
|
rateLimit: {
|
||||||
|
maxRequests: finalConfig.maxRequests,
|
||||||
|
maxRPS: finalConfig.maxRequestsPerSecond,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.pin = pin;
|
||||||
|
this.tmdb = new TheMovieDb();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getInstance(): Promise<Tvdb> {
|
||||||
|
if (!this.instance) {
|
||||||
|
this.instance = new Tvdb();
|
||||||
|
await this.instance.login();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshToken(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!this.token) {
|
||||||
|
await this.login();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64Url = this.token.split('.')[1];
|
||||||
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const payload = JSON.parse(Buffer.from(base64, 'base64').toString());
|
||||||
|
|
||||||
|
if (!payload.exp) {
|
||||||
|
await this.login();
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const diff = payload.exp - now;
|
||||||
|
|
||||||
|
// refresh token 1 week before expiration
|
||||||
|
if (diff < 604800) {
|
||||||
|
await this.login();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError('Failed to refresh token', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async test(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.login();
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError('Login failed', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(): Promise<TvdbLoginResponse> {
|
||||||
|
let body: { apiKey: string; pin?: string } = {
|
||||||
|
apiKey: 'd00d9ecb-a9d0-4860-958a-74b14a041405',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.pin) {
|
||||||
|
body = {
|
||||||
|
...body,
|
||||||
|
pin: this.pin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.post<TvdbBaseResponse<TvdbLoginResponse>>(
|
||||||
|
'/login',
|
||||||
|
{
|
||||||
|
...body,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.token = response.data.token;
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getShowByTvdbId({
|
||||||
|
tvdbId,
|
||||||
|
language,
|
||||||
|
}: {
|
||||||
|
tvdbId: number;
|
||||||
|
language?: string;
|
||||||
|
}): Promise<TmdbTvDetails> {
|
||||||
|
try {
|
||||||
|
const tmdbTvShow = await this.tmdb.getShowByTvdbId({
|
||||||
|
tvdbId: tvdbId,
|
||||||
|
language,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.refreshToken();
|
||||||
|
|
||||||
|
const validTvdbId = this.getTvdbIdFromTmdb(tmdbTvShow);
|
||||||
|
|
||||||
|
if (this.isValidTvdbId(validTvdbId)) {
|
||||||
|
return this.enrichTmdbShowWithTvdbData(tmdbTvShow, validTvdbId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmdbTvShow;
|
||||||
|
} catch (error) {
|
||||||
|
return tmdbTvShow;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError('Failed to fetch TV show details', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTvShow({
|
||||||
|
tvId,
|
||||||
|
language,
|
||||||
|
}: {
|
||||||
|
tvId: number;
|
||||||
|
language?: string;
|
||||||
|
}): Promise<TmdbTvDetails> {
|
||||||
|
try {
|
||||||
|
const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.refreshToken();
|
||||||
|
|
||||||
|
const tvdbId = this.getTvdbIdFromTmdb(tmdbTvShow);
|
||||||
|
|
||||||
|
if (this.isValidTvdbId(tvdbId)) {
|
||||||
|
return await this.enrichTmdbShowWithTvdbData(tmdbTvShow, tvdbId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmdbTvShow;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError('Failed to fetch TV show details', error);
|
||||||
|
return tmdbTvShow;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError('Failed to fetch TV show details', error);
|
||||||
|
return this.tmdb.getTvShow({ tvId, language });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTvSeason({
|
||||||
|
tvId,
|
||||||
|
seasonNumber,
|
||||||
|
language = Tvdb.DEFAULT_LANGUAGE,
|
||||||
|
}: {
|
||||||
|
tvId: number;
|
||||||
|
seasonNumber: number;
|
||||||
|
language?: string;
|
||||||
|
}): Promise<TmdbSeasonWithEpisodes> {
|
||||||
|
try {
|
||||||
|
const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.refreshToken();
|
||||||
|
|
||||||
|
const tvdbId = this.getTvdbIdFromTmdb(tmdbTvShow);
|
||||||
|
|
||||||
|
if (!this.isValidTvdbId(tvdbId)) {
|
||||||
|
return await this.tmdb.getTvSeason({ tvId, seasonNumber, language });
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.getTvdbSeasonData(
|
||||||
|
tvdbId,
|
||||||
|
seasonNumber,
|
||||||
|
tvId,
|
||||||
|
language
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError('Failed to fetch TV season details', error);
|
||||||
|
return await this.tmdb.getTvSeason({ tvId, seasonNumber, language });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[TVDB] Failed to fetch TV season details: ${error.message}`
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enrichTmdbShowWithTvdbData(
|
||||||
|
tmdbTvShow: TmdbTvDetails,
|
||||||
|
tvdbId: ValidTvdbId
|
||||||
|
): Promise<TmdbTvDetails> {
|
||||||
|
try {
|
||||||
|
await this.refreshToken();
|
||||||
|
|
||||||
|
const tvdbData = await this.fetchTvdbShowData(tvdbId);
|
||||||
|
const seasons = this.processSeasons(tvdbData);
|
||||||
|
|
||||||
|
if (!seasons.length) {
|
||||||
|
return tmdbTvShow;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...tmdbTvShow, seasons };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to enrich TMDB show with TVDB data: ${error.message} token: ${this.token}`
|
||||||
|
);
|
||||||
|
return tmdbTvShow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchTvdbShowData(tvdbId: number): Promise<TvdbTvDetails> {
|
||||||
|
const resp = await this.get<TvdbBaseResponse<TvdbTvDetails>>(
|
||||||
|
`/series/${tvdbId}/extended?meta=episodes&short=true`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tvdb.DEFAULT_CACHE_TTL
|
||||||
|
);
|
||||||
|
|
||||||
|
return resp.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private processSeasons(tvdbData: TvdbTvDetails): TmdbTvSeasonResult[] {
|
||||||
|
if (!tvdbData || !tvdbData.seasons || !tvdbData.episodes) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasons = tvdbData.seasons
|
||||||
|
.filter((season) => season.type && season.type.type === 'official')
|
||||||
|
.sort((a, b) => a.number - b.number)
|
||||||
|
.map((season) => this.createSeasonData(season, tvdbData))
|
||||||
|
.filter(
|
||||||
|
(season) => season && season.season_number >= 0
|
||||||
|
) as TmdbTvSeasonResult[];
|
||||||
|
|
||||||
|
return seasons;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSeasonData(
|
||||||
|
season: TvdbSeasonDetails,
|
||||||
|
tvdbData: TvdbTvDetails
|
||||||
|
): TmdbTvSeasonResult {
|
||||||
|
const seasonNumber = season.number ?? -1;
|
||||||
|
if (seasonNumber < 0) {
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
episode_count: 0,
|
||||||
|
name: '',
|
||||||
|
overview: '',
|
||||||
|
season_number: -1,
|
||||||
|
poster_path: '',
|
||||||
|
air_date: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodeCount = tvdbData.episodes.filter(
|
||||||
|
(episode) => episode.seasonNumber === season.number
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: tvdbData.id,
|
||||||
|
episode_count: episodeCount,
|
||||||
|
name: `${season.number}`,
|
||||||
|
overview: '',
|
||||||
|
season_number: season.number,
|
||||||
|
poster_path: '',
|
||||||
|
air_date: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTvdbSeasonData(
|
||||||
|
tvdbId: number,
|
||||||
|
seasonNumber: number,
|
||||||
|
tvId: number,
|
||||||
|
language: string = Tvdb.DEFAULT_LANGUAGE
|
||||||
|
): Promise<TmdbSeasonWithEpisodes> {
|
||||||
|
const tvdbData = await this.fetchTvdbShowData(tvdbId);
|
||||||
|
|
||||||
|
if (!tvdbData) {
|
||||||
|
logger.error(`Failed to fetch TVDB data for ID: ${tvdbId}`);
|
||||||
|
return this.createEmptySeasonResponse(tvId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// get season id
|
||||||
|
const season = tvdbData.seasons.find(
|
||||||
|
(season) =>
|
||||||
|
season.number === seasonNumber &&
|
||||||
|
season.type.type &&
|
||||||
|
season.type.type === 'official'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!season) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}`
|
||||||
|
);
|
||||||
|
return this.createEmptySeasonResponse(tvId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wantedTranslation = convertTmdbLanguageToTvdbWithFallback(
|
||||||
|
language,
|
||||||
|
Tvdb.DEFAULT_LANGUAGE
|
||||||
|
);
|
||||||
|
|
||||||
|
// check if translation is available for the season
|
||||||
|
const availableTranslation = season.nameTranslations.filter(
|
||||||
|
(translation) =>
|
||||||
|
translation === wantedTranslation ||
|
||||||
|
translation === Tvdb.DEFAULT_LANGUAGE
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!availableTranslation) {
|
||||||
|
return this.getSeasonWithOriginalLanguage(
|
||||||
|
tvdbId,
|
||||||
|
tvId,
|
||||||
|
seasonNumber,
|
||||||
|
season
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getSeasonWithTranslation(
|
||||||
|
tvdbId,
|
||||||
|
tvId,
|
||||||
|
seasonNumber,
|
||||||
|
season,
|
||||||
|
wantedTranslation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSeasonWithTranslation(
|
||||||
|
tvdbId: number,
|
||||||
|
tvId: number,
|
||||||
|
seasonNumber: number,
|
||||||
|
season: TvdbSeasonDetails,
|
||||||
|
language: string
|
||||||
|
): Promise<TmdbSeasonWithEpisodes> {
|
||||||
|
if (!season) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}`
|
||||||
|
);
|
||||||
|
return this.createEmptySeasonResponse(tvId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allEpisodes = [] as TvdbEpisode[];
|
||||||
|
let page = 0;
|
||||||
|
// Limit to max 50 pages to avoid infinite loops.
|
||||||
|
// 50 pages with 500 items per page = 25_000 episodes in a series which should be more than enough
|
||||||
|
const maxPages = 50;
|
||||||
|
|
||||||
|
while (page < maxPages) {
|
||||||
|
const resp = await this.get<TvdbBaseResponse<TvdbSeasonDetails>>(
|
||||||
|
`/series/${tvdbId}/episodes/default/${language}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
page: page,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!resp?.data?.episodes) {
|
||||||
|
logger.warn(
|
||||||
|
`No episodes found for TVDB ID: ${tvdbId} on page ${page} for season ${seasonNumber}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { episodes } = resp.data;
|
||||||
|
|
||||||
|
if (!episodes) {
|
||||||
|
logger.debug(
|
||||||
|
`No more episodes found for TVDB ID: ${tvdbId} on page ${page} for season ${seasonNumber}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
allEpisodes.push(...episodes);
|
||||||
|
|
||||||
|
const hasNextPage = resp.links?.next && episodes.length > 0;
|
||||||
|
|
||||||
|
if (!hasNextPage) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page >= maxPages) {
|
||||||
|
logger.warn(
|
||||||
|
`Reached max pages (${maxPages}) for TVDB ID: ${tvdbId} on season ${seasonNumber} with language ${language}. There might be more episodes available.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodes = this.processEpisodes(
|
||||||
|
{ ...season, episodes: allEpisodes },
|
||||||
|
seasonNumber,
|
||||||
|
tvId
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
episodes,
|
||||||
|
external_ids: { tvdb_id: tvdbId },
|
||||||
|
name: '',
|
||||||
|
overview: '',
|
||||||
|
id: season.id,
|
||||||
|
air_date: season.firstAired,
|
||||||
|
season_number: episodes.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSeasonWithOriginalLanguage(
|
||||||
|
tvdbId: number,
|
||||||
|
tvId: number,
|
||||||
|
seasonNumber: number,
|
||||||
|
season: TvdbSeasonDetails
|
||||||
|
): Promise<TmdbSeasonWithEpisodes> {
|
||||||
|
if (!season) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}`
|
||||||
|
);
|
||||||
|
return this.createEmptySeasonResponse(tvId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await this.get<TvdbBaseResponse<TvdbSeasonDetails>>(
|
||||||
|
`/seasons/${season.id}/extended`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const seasons = resp.data;
|
||||||
|
|
||||||
|
const episodes = this.processEpisodes(seasons, seasonNumber, tvId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
episodes,
|
||||||
|
external_ids: { tvdb_id: tvdbId },
|
||||||
|
name: '',
|
||||||
|
overview: '',
|
||||||
|
id: seasons.id,
|
||||||
|
air_date: seasons.firstAired,
|
||||||
|
season_number: episodes.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private processEpisodes(
|
||||||
|
tvdbSeason: TvdbSeasonDetails,
|
||||||
|
seasonNumber: number,
|
||||||
|
tvId: number
|
||||||
|
): TmdbTvEpisodeResult[] {
|
||||||
|
if (!tvdbSeason || !tvdbSeason.episodes) {
|
||||||
|
logger.error('No episodes found in TVDB season data');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return tvdbSeason.episodes
|
||||||
|
.filter((episode) => episode.seasonNumber === seasonNumber)
|
||||||
|
.map((episode, index) => this.createEpisodeData(episode, index, tvId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private createEpisodeData(
|
||||||
|
episode: TvdbEpisode,
|
||||||
|
index: number,
|
||||||
|
tvId: number
|
||||||
|
): TmdbTvEpisodeResult {
|
||||||
|
return {
|
||||||
|
id: episode.id,
|
||||||
|
air_date: episode.aired,
|
||||||
|
episode_number: episode.number,
|
||||||
|
name: episode.name || `Episode ${index + 1}`,
|
||||||
|
overview: episode.overview || '',
|
||||||
|
season_number: episode.seasonNumber,
|
||||||
|
production_code: '',
|
||||||
|
show_id: tvId,
|
||||||
|
still_path:
|
||||||
|
episode.image && !episode.image.startsWith('https://')
|
||||||
|
? 'https://artworks.thetvdb.com' + episode.image
|
||||||
|
: '',
|
||||||
|
vote_average: 1,
|
||||||
|
vote_count: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private createEmptySeasonResponse(tvId: number): TmdbSeasonWithEpisodes {
|
||||||
|
return {
|
||||||
|
episodes: [],
|
||||||
|
external_ids: { tvdb_id: tvId },
|
||||||
|
name: '',
|
||||||
|
overview: '',
|
||||||
|
id: 0,
|
||||||
|
air_date: '',
|
||||||
|
season_number: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTvdbIdFromTmdb(tmdbTvShow: TmdbTvDetails): TvdbId {
|
||||||
|
return tmdbTvShow?.external_ids?.tvdb_id ?? TvdbIdStatus.INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isValidTvdbId(tvdbId: TvdbId): tvdbId is ValidTvdbId {
|
||||||
|
return tvdbId !== TvdbIdStatus.INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleError(context: string, error: Error): void {
|
||||||
|
throw new Error(`[TVDB] ${context}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Tvdb;
|
||||||
216
server/api/tvdb/interfaces.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { type AvailableLocale } from '@server/types/languages';
|
||||||
|
|
||||||
|
export interface TvdbBaseResponse<T> {
|
||||||
|
data: T;
|
||||||
|
errors: string;
|
||||||
|
links?: TvdbPagination;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TvdbPagination {
|
||||||
|
prev?: string;
|
||||||
|
self: string;
|
||||||
|
next?: string;
|
||||||
|
totalItems: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TvdbLoginResponse {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TvDetailsAliases {
|
||||||
|
language: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TvDetailsStatus {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
recordType: string;
|
||||||
|
keepUpdated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TvdbTvDetails {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
image: string;
|
||||||
|
nameTranslations: string[];
|
||||||
|
overwiewTranslations: string[];
|
||||||
|
aliases: TvDetailsAliases[];
|
||||||
|
firstAired: Date;
|
||||||
|
lastAired: Date;
|
||||||
|
nextAired: Date | string;
|
||||||
|
score: number;
|
||||||
|
status: TvDetailsStatus;
|
||||||
|
originalCountry: string;
|
||||||
|
originalLanguage: string;
|
||||||
|
defaultSeasonType: string;
|
||||||
|
isOrderRandomized: boolean;
|
||||||
|
lastUpdated: Date;
|
||||||
|
averageRuntime: number;
|
||||||
|
seasons: TvdbSeasonDetails[];
|
||||||
|
episodes: TvdbEpisode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TvdbCompanyType {
|
||||||
|
companyTypeId: number;
|
||||||
|
companyTypeName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TvdbParentCompany {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
relation?: {
|
||||||
|
id?: number;
|
||||||
|
typeName?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TvdbCompany {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
nameTranslations?: string[];
|
||||||
|
overviewTranslations?: string[];
|
||||||
|
aliases?: string[];
|
||||||
|
country: string;
|
||||||
|
primaryCompanyType: number;
|
||||||
|
activeDate: string;
|
||||||
|
inactiveDate?: string;
|
||||||
|
companyType: TvdbCompanyType;
|
||||||
|
parentCompany: TvdbParentCompany;
|
||||||
|
tagOptions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TvdbType {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
alternateName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TvdbArtwork {
|
||||||
|
id: number;
|
||||||
|
image: string;
|
||||||
|
thumbnail: string;
|
||||||
|
language: string;
|
||||||
|
type: number;
|
||||||
|
score: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
includesText: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TvdbEpisode {
|
||||||
|
id: number;
|
||||||
|
seriesId: number;
|
||||||
|
name: string;
|
||||||
|
aired: string;
|
||||||
|
runtime: number;
|
||||||
|
nameTranslations: string[];
|
||||||
|
overview?: string;
|
||||||
|
overviewTranslations: string[];
|
||||||
|
image: string;
|
||||||
|
imageType: number;
|
||||||
|
isMovie: number;
|
||||||
|
seasons?: string[];
|
||||||
|
number: number;
|
||||||
|
absoluteNumber: number;
|
||||||
|
seasonNumber: number;
|
||||||
|
lastUpdated: string;
|
||||||
|
finaleType?: string;
|
||||||
|
year: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TvdbSeasonDetails {
|
||||||
|
id: number;
|
||||||
|
seriesId: number;
|
||||||
|
type: TvdbType;
|
||||||
|
number: number;
|
||||||
|
nameTranslations: string[];
|
||||||
|
overviewTranslations: string[];
|
||||||
|
image: string;
|
||||||
|
imageType: number;
|
||||||
|
companies: {
|
||||||
|
studio: TvdbCompany[];
|
||||||
|
network: TvdbCompany[];
|
||||||
|
production: TvdbCompany[];
|
||||||
|
distributor: TvdbCompany[];
|
||||||
|
special_effects: TvdbCompany[];
|
||||||
|
};
|
||||||
|
lastUpdated: string;
|
||||||
|
year: string;
|
||||||
|
episodes: TvdbEpisode[];
|
||||||
|
trailers: string[];
|
||||||
|
artwork: TvdbArtwork[];
|
||||||
|
tagOptions?: string[];
|
||||||
|
firstAired: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TvdbEpisodeTranslation {
|
||||||
|
name: string;
|
||||||
|
overview: string;
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TMDB_TO_TVDB_MAPPING: Record<string, string> & {
|
||||||
|
[key in AvailableLocale]: string;
|
||||||
|
} = {
|
||||||
|
ar: 'ara', // Arabic
|
||||||
|
bg: 'bul', // Bulgarian
|
||||||
|
ca: 'cat', // Catalan
|
||||||
|
cs: 'ces', // Czech
|
||||||
|
da: 'dan', // Danish
|
||||||
|
de: 'deu', // German
|
||||||
|
el: 'ell', // Greek
|
||||||
|
en: 'eng', // English
|
||||||
|
es: 'spa', // Spanish
|
||||||
|
fi: 'fin', // Finnish
|
||||||
|
fr: 'fra', // French
|
||||||
|
he: 'heb', // Hebrew
|
||||||
|
hi: 'hin', // Hindi
|
||||||
|
hr: 'hrv', // Croatian
|
||||||
|
hu: 'hun', // Hungarian
|
||||||
|
it: 'ita', // Italian
|
||||||
|
ja: 'jpn', // Japanese
|
||||||
|
ko: 'kor', // Korean
|
||||||
|
lt: 'lit', // Lithuanian
|
||||||
|
nl: 'nld', // Dutch
|
||||||
|
pl: 'pol', // Polish
|
||||||
|
ro: 'ron', // Romanian
|
||||||
|
ru: 'rus', // Russian
|
||||||
|
sq: 'sqi', // Albanian
|
||||||
|
sr: 'srp', // Serbian
|
||||||
|
sv: 'swe', // Swedish
|
||||||
|
tr: 'tur', // Turkish
|
||||||
|
uk: 'ukr', // Ukrainian
|
||||||
|
|
||||||
|
'es-MX': 'spa', // Spanish (Latin America) -> Spanish
|
||||||
|
'nb-NO': 'nor', // Norwegian Bokmål -> Norwegian
|
||||||
|
'pt-BR': 'pt', // Portuguese (Brazil) -> Portuguese - Brazil (from TVDB data)
|
||||||
|
'pt-PT': 'por', // Portuguese (Portugal) -> Portuguese - Portugal (from TVDB data)
|
||||||
|
'zh-CN': 'zho', // Chinese (Simplified) -> Chinese - China
|
||||||
|
'zh-TW': 'zhtw', // Chinese (Traditional) -> Chinese - Taiwan
|
||||||
|
};
|
||||||
|
|
||||||
|
export function convertTMDBToTVDB(tmdbCode: string): string | null {
|
||||||
|
const normalizedCode = tmdbCode.toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
TMDB_TO_TVDB_MAPPING[tmdbCode] ||
|
||||||
|
TMDB_TO_TVDB_MAPPING[normalizedCode] ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertTmdbLanguageToTvdbWithFallback(
|
||||||
|
tmdbCode: string,
|
||||||
|
fallback: string
|
||||||
|
): string {
|
||||||
|
// First try exact match
|
||||||
|
const tvdbCode = convertTMDBToTVDB(tmdbCode);
|
||||||
|
if (tvdbCode) return tvdbCode;
|
||||||
|
|
||||||
|
return tvdbCode || fallback || 'eng'; // Default to English if no match found
|
||||||
|
}
|
||||||
@@ -7,5 +7,6 @@ export enum ApiErrorCode {
|
|||||||
NoAdminUser = 'NO_ADMIN_USER',
|
NoAdminUser = 'NO_ADMIN_USER',
|
||||||
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
|
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
|
||||||
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
|
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
|
||||||
|
Unauthorized = 'UNAUTHORIZED',
|
||||||
Unknown = 'UNKNOWN',
|
Unknown = 'UNKNOWN',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export enum MediaRequestStatus {
|
|||||||
APPROVED,
|
APPROVED,
|
||||||
DECLINED,
|
DECLINED,
|
||||||
FAILED,
|
FAILED,
|
||||||
|
COMPLETED,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum MediaType {
|
export enum MediaType {
|
||||||
@@ -17,4 +18,5 @@ export enum MediaStatus {
|
|||||||
PARTIALLY_AVAILABLE,
|
PARTIALLY_AVAILABLE,
|
||||||
AVAILABLE,
|
AVAILABLE,
|
||||||
BLACKLISTED,
|
BLACKLISTED,
|
||||||
|
DELETED,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,8 +68,10 @@ const prodConfig: DataSourceOptions = {
|
|||||||
|
|
||||||
const postgresDevConfig: DataSourceOptions = {
|
const postgresDevConfig: DataSourceOptions = {
|
||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_SOCKET_PATH || process.env.DB_HOST,
|
||||||
port: parseInt(process.env.DB_PORT ?? '5432'),
|
port: process.env.DB_SOCKET_PATH
|
||||||
|
? undefined
|
||||||
|
: parseInt(process.env.DB_PORT ?? '5432'),
|
||||||
username: process.env.DB_USER,
|
username: process.env.DB_USER,
|
||||||
password: process.env.DB_PASS,
|
password: process.env.DB_PASS,
|
||||||
database: process.env.DB_NAME ?? 'jellyseerr',
|
database: process.env.DB_NAME ?? 'jellyseerr',
|
||||||
@@ -84,8 +86,10 @@ const postgresDevConfig: DataSourceOptions = {
|
|||||||
|
|
||||||
const postgresProdConfig: DataSourceOptions = {
|
const postgresProdConfig: DataSourceOptions = {
|
||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_SOCKET_PATH || process.env.DB_HOST,
|
||||||
port: parseInt(process.env.DB_PORT ?? '5432'),
|
port: process.env.DB_SOCKET_PATH
|
||||||
|
? undefined
|
||||||
|
: parseInt(process.env.DB_PORT ?? '5432'),
|
||||||
username: process.env.DB_USER,
|
username: process.env.DB_USER,
|
||||||
password: process.env.DB_PASS,
|
password: process.env.DB_PASS,
|
||||||
database: process.env.DB_NAME ?? 'jellyseerr',
|
database: process.env.DB_NAME ?? 'jellyseerr',
|
||||||
|
|||||||