Compare commits
52 Commits
fix-pendin
...
preview-av
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ac21bb814 | ||
|
|
3f8ebc75d5 | ||
|
|
065d3002e0 | ||
|
|
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 |
@@ -277,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"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -295,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"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -380,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",
|
||||||
@@ -452,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",
|
||||||
@@ -623,87 +535,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": "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": "JackW6809",
|
|
||||||
"name": "JackOXI",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/53652452?v=4",
|
|
||||||
"profile": "https://github.com/JackW6809",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "StancuFlorin",
|
|
||||||
"name": "Stancu Florin",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/1199404?v=4",
|
|
||||||
"profile": "http://indicus.ro",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"login": "RankWeis",
|
"login": "RankWeis",
|
||||||
"name": "RankWeis",
|
"name": "RankWeis",
|
||||||
@@ -713,105 +544,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": "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": "JackW6809",
|
|
||||||
"name": "JackOXI",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/53652452?v=4",
|
|
||||||
"profile": "https://github.com/JackW6809",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "StancuFlorin",
|
|
||||||
"name": "Stancu Florin",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/1199404?v=4",
|
|
||||||
"profile": "http://indicus.ro",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "lmiklosko",
|
|
||||||
"name": "Lukas Miklosko",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/44380311?v=4",
|
|
||||||
"profile": "https://github.com/lmiklosko",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "gauthier-th",
|
|
||||||
"name": "Gauthier",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/37781713?v=4",
|
|
||||||
"profile": "https://gauthierth.fr/",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"login": "jessielw",
|
"login": "jessielw",
|
||||||
"name": "Jessie Wilson",
|
"name": "Jessie Wilson",
|
||||||
@@ -847,6 +579,69 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"code"
|
"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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
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"]
|
||||||
}
|
}
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@@ -42,13 +42,13 @@ FROM node:22-alpine
|
|||||||
ARG BUILD_DATE
|
ARG BUILD_DATE
|
||||||
ARG BUILD_VERSION
|
ARG BUILD_VERSION
|
||||||
LABEL \
|
LABEL \
|
||||||
org.opencontainers.image.authors="Fallenbagel" \
|
org.opencontainers.image.authors="Fallenbagel" \
|
||||||
org.opencontainers.image.source="https://github.com/fallenbagel/jellyseerr" \
|
org.opencontainers.image.source="https://github.com/fallenbagel/jellyseerr" \
|
||||||
org.opencontainers.image.created=${BUILD_DATE} \
|
org.opencontainers.image.created=${BUILD_DATE} \
|
||||||
org.opencontainers.image.version=${BUILD_VERSION} \
|
org.opencontainers.image.version=${BUILD_VERSION} \
|
||||||
org.opencontainers.image.title="Jellyseerr" \
|
org.opencontainers.image.title="Jellyseerr" \
|
||||||
org.opencontainers.image.description="Open-source media request and discovery manager for Jellyfin, Plex, and Emby." \
|
org.opencontainers.image.description="Open-source media request and discovery manager for Jellyfin, Plex, and Emby." \
|
||||||
org.opencontainers.image.licenses="MIT"
|
org.opencontainers.image.licenses="MIT"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
68
README.md
68
README.md
@@ -11,7 +11,7 @@
|
|||||||
<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-92-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. 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/)**.
|
**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/)**.
|
||||||
@@ -122,9 +122,9 @@ 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/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>
|
||||||
@@ -136,74 +136,43 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
<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>
|
||||||
|
</tr>
|
||||||
|
<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/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/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>
|
|
||||||
</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/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/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="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://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/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/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/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>
|
||||||
</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="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://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>
|
<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>
|
||||||
<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/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/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/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="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="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://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://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>
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<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/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/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/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://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>
|
<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>
|
||||||
<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="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="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>
|
|
||||||
<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>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<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="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://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/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/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/fallenbagel/jellyseerr/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/fallenbagel/jellyseerr/commits?author=StancuFlorin" 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/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>
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<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>
|
|
||||||
<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/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="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://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/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>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<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/fallenbagel/jellyseerr/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/fallenbagel/jellyseerr/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/fallenbagel/jellyseerr/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/fallenbagel/jellyseerr/commits?author=gauthier-th" 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="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/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://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://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>
|
||||||
<tr>
|
<tr>
|
||||||
<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/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>
|
||||||
@@ -338,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>
|
||||||
@@ -358,6 +327,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
<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="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://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://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>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ kubeVersion: ">=1.23.0-0"
|
|||||||
name: jellyseerr-chart
|
name: jellyseerr-chart
|
||||||
description: Jellyseerr helm chart for Kubernetes
|
description: Jellyseerr helm chart for Kubernetes
|
||||||
type: application
|
type: application
|
||||||
version: 2.3.3
|
version: 2.6.0
|
||||||
appVersion: "2.5.2"
|
appVersion: "2.7.0"
|
||||||
maintainers:
|
maintainers:
|
||||||
- name: Jellyseerr
|
- name: Jellyseerr
|
||||||
url: https://github.com/Fallenbagel/jellyseerr
|
url: https://github.com/Fallenbagel/jellyseerr
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# jellyseerr-chart
|
# jellyseerr-chart
|
||||||
|
|
||||||
  
|
  
|
||||||
|
|
||||||
Jellyseerr helm chart for Kubernetes
|
Jellyseerr helm chart for Kubernetes
|
||||||
|
|
||||||
@@ -52,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 | `{}` | |
|
||||||
|
|||||||
@@ -48,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 }}
|
||||||
|
|||||||
@@ -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,16 +104,16 @@ 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
|
||||||
|
|
||||||
# -- Additional volumes on the output Deployment definition.
|
# -- Additional volumes on the output Deployment definition.
|
||||||
volumes: []
|
volumes: []
|
||||||
|
|||||||
@@ -142,6 +142,14 @@
|
|||||||
"token": "",
|
"token": "",
|
||||||
"priority": 0
|
"priority": 0
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"ntfy": {
|
||||||
|
"enabled": false,
|
||||||
|
"types": 0,
|
||||||
|
"options": {
|
||||||
|
"url": "",
|
||||||
|
"topic": ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,13 +37,8 @@ docker run -d \
|
|||||||
-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 \
|
||||||
ghcr.io/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`
|
|
||||||
:::
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -55,7 +50,7 @@ docker stop jellyseerr && docker rm Jellyseerr
|
|||||||
```
|
```
|
||||||
Pull the latest image:
|
Pull the latest image:
|
||||||
```bash
|
```bash
|
||||||
docker pull ghcr.io/fallenbagel/jellyseerr
|
docker pull fallenbagel/jellyseerr
|
||||||
```
|
```
|
||||||
Finally, run the container with the same parameters originally used to create the container:
|
Finally, run the container with the same parameters originally used to create the container:
|
||||||
```bash
|
```bash
|
||||||
@@ -78,7 +73,7 @@ Define the `jellyseerr` service in your `compose.yaml` as follows:
|
|||||||
---
|
---
|
||||||
services:
|
services:
|
||||||
jellyseerr:
|
jellyseerr:
|
||||||
image: ghcr.io/fallenbagel/jellyseerr:latest
|
image: fallenbagel/jellyseerr:latest
|
||||||
container_name: jellyseerr
|
container_name: jellyseerr
|
||||||
environment:
|
environment:
|
||||||
- LOG_LEVEL=debug
|
- LOG_LEVEL=debug
|
||||||
@@ -90,9 +85,6 @@ services:
|
|||||||
- /path/to/appdata/config:/app/config
|
- /path/to/appdata/config:/app/config
|
||||||
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
|
||||||
@@ -121,8 +113,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
|
||||||
|
|
||||||
@@ -146,7 +137,7 @@ 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 ghcr.io/fallenbagel/jellyseerr:latest
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Updating:
|
#### Updating:
|
||||||
@@ -193,12 +184,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
|
||||||
|
|||||||
@@ -105,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:
|
||||||
@@ -146,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.
|
||||||
|
|||||||
@@ -78,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.
|
||||||
|
|||||||
@@ -1157,7 +1157,7 @@ components:
|
|||||||
status:
|
status:
|
||||||
type: number
|
type: number
|
||||||
example: 0
|
example: 0
|
||||||
description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`
|
description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`, 6 = `DELETED`
|
||||||
requests:
|
requests:
|
||||||
type: array
|
type: array
|
||||||
readOnly: true
|
readOnly: true
|
||||||
@@ -1399,6 +1399,32 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
token:
|
token:
|
||||||
type: string
|
type: string
|
||||||
|
NtfySettings:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
example: false
|
||||||
|
types:
|
||||||
|
type: number
|
||||||
|
example: 2
|
||||||
|
options:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
topic:
|
||||||
|
type: string
|
||||||
|
authMethodUsernamePassword:
|
||||||
|
type: boolean
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
authMethodToken:
|
||||||
|
type: boolean
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
LunaSeaSettings:
|
LunaSeaSettings:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -1950,6 +1976,41 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
|
Certification:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
certification:
|
||||||
|
type: string
|
||||||
|
example: 'PG-13'
|
||||||
|
meaning:
|
||||||
|
type: string
|
||||||
|
example: 'Some material may be inappropriate for children under 13.'
|
||||||
|
nullable: true
|
||||||
|
order:
|
||||||
|
type: number
|
||||||
|
example: 3
|
||||||
|
nullable: true
|
||||||
|
required:
|
||||||
|
- certification
|
||||||
|
|
||||||
|
CertificationResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
certifications:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Certification'
|
||||||
|
example:
|
||||||
|
certifications:
|
||||||
|
US:
|
||||||
|
- certification: 'G'
|
||||||
|
meaning: 'All ages admitted'
|
||||||
|
order: 1
|
||||||
|
- certification: 'PG'
|
||||||
|
meaning: 'Some material may not be suitable for children under 10.'
|
||||||
|
order: 2
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
cookieAuth:
|
cookieAuth:
|
||||||
type: apiKey
|
type: apiKey
|
||||||
@@ -3249,6 +3310,52 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'204':
|
'204':
|
||||||
description: Test notification attempted
|
description: Test notification attempted
|
||||||
|
/settings/notifications/ntfy:
|
||||||
|
get:
|
||||||
|
summary: Get ntfy.sh notification settings
|
||||||
|
description: Returns current ntfy.sh notification settings in a JSON object.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Returned ntfy.sh settings
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/NtfySettings'
|
||||||
|
post:
|
||||||
|
summary: Update ntfy.sh notification settings
|
||||||
|
description: Update ntfy.sh notification settings with the provided values.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/NtfySettings'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 'Values were sucessfully updated'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/NtfySettings'
|
||||||
|
/settings/notifications/ntfy/test:
|
||||||
|
post:
|
||||||
|
summary: Test ntfy.sh settings
|
||||||
|
description: Sends a test notification to the ntfy.sh agent.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/NtfySettings'
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Test notification attempted
|
||||||
/settings/notifications/slack:
|
/settings/notifications/slack:
|
||||||
get:
|
get:
|
||||||
summary: Get Slack notification settings
|
summary: Get Slack notification settings
|
||||||
@@ -4000,7 +4107,7 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
userAgent:
|
userAgent:
|
||||||
type: string
|
type: string
|
||||||
/user/{userId}/pushSubscription/{key}:
|
/user/{userId}/pushSubscription/{endpoint}:
|
||||||
get:
|
get:
|
||||||
summary: Get web push notification settings for a user
|
summary: Get web push notification settings for a user
|
||||||
description: |
|
description: |
|
||||||
@@ -4014,7 +4121,7 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: number
|
type: number
|
||||||
- in: path
|
- in: path
|
||||||
name: key
|
name: endpoint
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
@@ -4046,7 +4153,7 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: number
|
type: number
|
||||||
- in: path
|
- in: path
|
||||||
name: key
|
name: endpoint
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
@@ -4954,6 +5061,37 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
example: 8|9
|
example: 8|9
|
||||||
|
- in: query
|
||||||
|
name: certification
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: PG-13
|
||||||
|
description: Exact certification to filter by (used when certificationMode is 'exact')
|
||||||
|
- in: query
|
||||||
|
name: certificationGte
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: G
|
||||||
|
description: Minimum certification to filter by (used when certificationMode is 'range')
|
||||||
|
- in: query
|
||||||
|
name: certificationLte
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: PG-13
|
||||||
|
description: Maximum certification to filter by (used when certificationMode is 'range')
|
||||||
|
- in: query
|
||||||
|
name: certificationCountry
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: US
|
||||||
|
description: Country code for the certification system (e.g., US, GB, CA)
|
||||||
|
- in: query
|
||||||
|
name: certificationMode
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [exact, range]
|
||||||
|
example: exact
|
||||||
|
description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API)
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Results
|
description: Results
|
||||||
@@ -5248,6 +5386,37 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
example: 3|4
|
example: 3|4
|
||||||
|
- in: query
|
||||||
|
name: certification
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: TV-14
|
||||||
|
description: Exact certification to filter by (used when certificationMode is 'exact')
|
||||||
|
- in: query
|
||||||
|
name: certificationGte
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: TV-PG
|
||||||
|
description: Minimum certification to filter by (used when certificationMode is 'range')
|
||||||
|
- in: query
|
||||||
|
name: certificationLte
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: TV-MA
|
||||||
|
description: Maximum certification to filter by (used when certificationMode is 'range')
|
||||||
|
- in: query
|
||||||
|
name: certificationCountry
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: US
|
||||||
|
description: Country code for the certification system (e.g., US, GB, CA)
|
||||||
|
- in: query
|
||||||
|
name: certificationMode
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [exact, range]
|
||||||
|
example: exact
|
||||||
|
description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API)
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Results
|
description: Results
|
||||||
@@ -5676,6 +5845,8 @@ paths:
|
|||||||
processing,
|
processing,
|
||||||
unavailable,
|
unavailable,
|
||||||
failed,
|
failed,
|
||||||
|
deleted,
|
||||||
|
completed,
|
||||||
]
|
]
|
||||||
- in: query
|
- in: query
|
||||||
name: sort
|
name: sort
|
||||||
@@ -5696,6 +5867,13 @@ paths:
|
|||||||
type: number
|
type: number
|
||||||
nullable: true
|
nullable: true
|
||||||
example: 1
|
example: 1
|
||||||
|
- in: query
|
||||||
|
name: mediaType
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [movie, tv, all]
|
||||||
|
nullable: true
|
||||||
|
default: all
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Requests returned
|
description: Requests returned
|
||||||
@@ -6422,7 +6600,16 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
enum: [all, available, partial, allavailable, processing, pending]
|
enum:
|
||||||
|
[
|
||||||
|
all,
|
||||||
|
available,
|
||||||
|
partial,
|
||||||
|
allavailable,
|
||||||
|
processing,
|
||||||
|
pending,
|
||||||
|
deleted,
|
||||||
|
]
|
||||||
- in: query
|
- in: query
|
||||||
name: sort
|
name: sort
|
||||||
schema:
|
schema:
|
||||||
@@ -6498,7 +6685,7 @@ paths:
|
|||||||
example: available
|
example: available
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
enum: [available, partial, processing, pending, unknown]
|
enum: [available, partial, processing, pending, unknown, deleted]
|
||||||
requestBody:
|
requestBody:
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
@@ -7210,6 +7397,64 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/WatchProviderDetails'
|
$ref: '#/components/schemas/WatchProviderDetails'
|
||||||
|
/certifications/movie:
|
||||||
|
get:
|
||||||
|
summary: Get movie certifications
|
||||||
|
description: Returns list of movie certifications from TMDB.
|
||||||
|
tags:
|
||||||
|
- other
|
||||||
|
security:
|
||||||
|
- cookieAuth: []
|
||||||
|
- apiKey: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Movie certifications returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CertificationResponse'
|
||||||
|
'500':
|
||||||
|
description: Unable to retrieve movie certifications
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: number
|
||||||
|
example: 500
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Unable to retrieve movie certifications.
|
||||||
|
/certifications/tv:
|
||||||
|
get:
|
||||||
|
summary: Get TV certifications
|
||||||
|
description: Returns list of TV show certifications from TMDB.
|
||||||
|
tags:
|
||||||
|
- other
|
||||||
|
security:
|
||||||
|
- cookieAuth: []
|
||||||
|
- apiKey: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: TV certifications returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CertificationResponse'
|
||||||
|
'500':
|
||||||
|
description: Unable to retrieve TV certifications
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: number
|
||||||
|
example: 500
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Unable to retrieve TV certifications.
|
||||||
/overrideRule:
|
/overrideRule:
|
||||||
get:
|
get:
|
||||||
summary: Get override rules
|
summary: Get override rules
|
||||||
|
|||||||
@@ -43,8 +43,8 @@
|
|||||||
"@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/wink-jaro-distance": "^2.0.2",
|
|
||||||
"@types/ua-parser-js": "^0.7.36",
|
"@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.3.4",
|
"axios": "1.3.4",
|
||||||
"axios-rate-limit": "1.3.0",
|
"axios-rate-limit": "1.3.0",
|
||||||
@@ -65,6 +65,8 @@
|
|||||||
"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",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"mime": "3",
|
"mime": "3",
|
||||||
"next": "^14.2.25",
|
"next": "^14.2.25",
|
||||||
@@ -100,9 +102,9 @@
|
|||||||
"swagger-ui-express": "4.6.2",
|
"swagger-ui-express": "4.6.2",
|
||||||
"swr": "2.2.5",
|
"swr": "2.2.5",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"typeorm": "0.3.11",
|
"typeorm": "0.3.12",
|
||||||
"undici": "^7.3.0",
|
|
||||||
"ua-parser-js": "^1.0.35",
|
"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",
|
"wink-jaro-distance": "^2.0.0",
|
||||||
"winston": "3.8.2",
|
"winston": "3.8.2",
|
||||||
|
|||||||
70
pnpm-lock.yaml
generated
70
pnpm-lock.yaml
generated
@@ -64,7 +64,7 @@ importers:
|
|||||||
version: 2.11.0
|
version: 2.11.0
|
||||||
connect-typeorm:
|
connect-typeorm:
|
||||||
specifier: 1.1.4
|
specifier: 1.1.4
|
||||||
version: 1.1.4(typeorm@0.3.11(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)))
|
version: 1.1.4(typeorm@0.3.12(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)))
|
||||||
cookie-parser:
|
cookie-parser:
|
||||||
specifier: 1.4.7
|
specifier: 1.4.7
|
||||||
version: 1.4.7
|
version: 1.4.7
|
||||||
@@ -107,6 +107,12 @@ importers:
|
|||||||
gravatar-url:
|
gravatar-url:
|
||||||
specifier: 3.1.0
|
specifier: 3.1.0
|
||||||
version: 3.1.0
|
version: 3.1.0
|
||||||
|
http-proxy-agent:
|
||||||
|
specifier: ^7.0.2
|
||||||
|
version: 7.0.2
|
||||||
|
https-proxy-agent:
|
||||||
|
specifier: ^7.0.6
|
||||||
|
version: 7.0.6
|
||||||
lodash:
|
lodash:
|
||||||
specifier: 4.17.21
|
specifier: 4.17.21
|
||||||
version: 4.17.21
|
version: 4.17.21
|
||||||
@@ -213,8 +219,8 @@ importers:
|
|||||||
specifier: ^2.6.0
|
specifier: ^2.6.0
|
||||||
version: 2.6.0
|
version: 2.6.0
|
||||||
typeorm:
|
typeorm:
|
||||||
specifier: 0.3.11
|
specifier: 0.3.12
|
||||||
version: 0.3.11(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))
|
version: 0.3.12(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))
|
||||||
ua-parser-js:
|
ua-parser-js:
|
||||||
specifier: ^1.0.35
|
specifier: ^1.0.35
|
||||||
version: 1.0.40
|
version: 1.0.40
|
||||||
@@ -3626,6 +3632,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==}
|
resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
|
agent-base@7.1.3:
|
||||||
|
resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==}
|
||||||
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
agentkeepalive@4.5.0:
|
agentkeepalive@4.5.0:
|
||||||
resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==}
|
resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==}
|
||||||
engines: {node: '>= 8.0.0'}
|
engines: {node: '>= 8.0.0'}
|
||||||
@@ -5788,8 +5798,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
https-proxy-agent@7.0.5:
|
https-proxy-agent@7.0.6:
|
||||||
resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==}
|
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
human-signals@1.1.1:
|
human-signals@1.1.1:
|
||||||
@@ -6973,6 +6983,11 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
mkdirp@2.1.6:
|
||||||
|
resolution: {integrity: sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
modify-values@1.0.1:
|
modify-values@1.0.1:
|
||||||
resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==}
|
resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -9190,8 +9205,8 @@ packages:
|
|||||||
typedarray@0.0.6:
|
typedarray@0.0.6:
|
||||||
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
|
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
|
||||||
|
|
||||||
typeorm@0.3.11:
|
typeorm@0.3.12:
|
||||||
resolution: {integrity: sha512-pzdOyWbVuz/z8Ww6gqvBW4nylsM0KLdUCDExr2gR20/x1khGSVxQkjNV/3YqliG90jrWzrknYbYscpk8yxFJVg==}
|
resolution: {integrity: sha512-sYSxBmCf1nJLLTcYtwqZ+lQIRtLPyUoO93rHTOKk9vJCyT4UfRtU7oRsJvfvKP3nnZTD1hzz2SEy2zwPEN6OyA==}
|
||||||
engines: {node: '>= 12.9.0'}
|
engines: {node: '>= 12.9.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -9202,7 +9217,7 @@ packages:
|
|||||||
ioredis: ^5.0.4
|
ioredis: ^5.0.4
|
||||||
mongodb: ^3.6.0
|
mongodb: ^3.6.0
|
||||||
mssql: ^7.3.0
|
mssql: ^7.3.0
|
||||||
mysql2: ^2.2.5
|
mysql2: ^2.2.5 || ^3.0.1
|
||||||
oracledb: ^5.1.0
|
oracledb: ^5.1.0
|
||||||
pg: ^8.5.1
|
pg: ^8.5.1
|
||||||
pg-native: ^3.0.0
|
pg-native: ^3.0.0
|
||||||
@@ -11117,7 +11132,7 @@ snapshots:
|
|||||||
'@babel/helper-split-export-declaration': 7.24.7
|
'@babel/helper-split-export-declaration': 7.24.7
|
||||||
'@babel/parser': 7.24.7
|
'@babel/parser': 7.24.7
|
||||||
'@babel/types': 7.24.7
|
'@babel/types': 7.24.7
|
||||||
debug: 4.3.5
|
debug: 4.4.0(supports-color@5.5.0)
|
||||||
globals: 11.12.0
|
globals: 11.12.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -13334,7 +13349,7 @@ snapshots:
|
|||||||
fs-extra: 11.2.0
|
fs-extra: 11.2.0
|
||||||
globby: 11.1.0
|
globby: 11.1.0
|
||||||
http-proxy-agent: 7.0.2
|
http-proxy-agent: 7.0.2
|
||||||
https-proxy-agent: 7.0.5
|
https-proxy-agent: 7.0.6
|
||||||
issue-parser: 6.0.0
|
issue-parser: 6.0.0
|
||||||
lodash: 4.17.21
|
lodash: 4.17.21
|
||||||
mime: 3.0.0
|
mime: 3.0.0
|
||||||
@@ -13956,7 +13971,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/types': 7.2.0
|
'@typescript-eslint/types': 7.2.0
|
||||||
'@typescript-eslint/visitor-keys': 7.2.0
|
'@typescript-eslint/visitor-keys': 7.2.0
|
||||||
debug: 4.3.5
|
debug: 4.4.0(supports-color@5.5.0)
|
||||||
globby: 11.1.0
|
globby: 11.1.0
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
minimatch: 9.0.3
|
minimatch: 9.0.3
|
||||||
@@ -14047,10 +14062,12 @@ snapshots:
|
|||||||
|
|
||||||
agent-base@7.1.1:
|
agent-base@7.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.3.5
|
debug: 4.4.0(supports-color@5.5.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
agent-base@7.1.3: {}
|
||||||
|
|
||||||
agentkeepalive@4.5.0:
|
agentkeepalive@4.5.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
humanize-ms: 1.2.1
|
humanize-ms: 1.2.1
|
||||||
@@ -14913,13 +14930,13 @@ snapshots:
|
|||||||
ini: 1.3.8
|
ini: 1.3.8
|
||||||
proto-list: 1.2.4
|
proto-list: 1.2.4
|
||||||
|
|
||||||
connect-typeorm@1.1.4(typeorm@0.3.11(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))):
|
connect-typeorm@1.1.4(typeorm@0.3.12(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/debug': 0.0.31
|
'@types/debug': 0.0.31
|
||||||
'@types/express-session': 1.17.6
|
'@types/express-session': 1.17.6
|
||||||
debug: 4.4.0(supports-color@5.5.0)
|
debug: 4.4.0(supports-color@5.5.0)
|
||||||
express-session: 1.17.3
|
express-session: 1.17.3
|
||||||
typeorm: 0.3.11(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))
|
typeorm: 0.3.12(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -16816,7 +16833,7 @@ snapshots:
|
|||||||
http-proxy-agent@7.0.2:
|
http-proxy-agent@7.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.1
|
agent-base: 7.1.1
|
||||||
debug: 4.3.5
|
debug: 4.4.0(supports-color@5.5.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -16843,10 +16860,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
https-proxy-agent@7.0.5:
|
https-proxy-agent@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.1
|
agent-base: 7.1.3
|
||||||
debug: 4.3.5
|
debug: 4.4.0(supports-color@5.5.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -18282,6 +18299,8 @@ snapshots:
|
|||||||
|
|
||||||
mkdirp@1.0.4: {}
|
mkdirp@1.0.4: {}
|
||||||
|
|
||||||
|
mkdirp@2.1.6: {}
|
||||||
|
|
||||||
modify-values@1.0.1: {}
|
modify-values@1.0.1: {}
|
||||||
|
|
||||||
moment@2.30.1: {}
|
moment@2.30.1: {}
|
||||||
@@ -20699,7 +20718,7 @@ snapshots:
|
|||||||
|
|
||||||
typedarray@0.0.6: {}
|
typedarray@0.0.6: {}
|
||||||
|
|
||||||
typeorm@0.3.11(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)):
|
typeorm@0.3.12(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sqltools/formatter': 1.2.5
|
'@sqltools/formatter': 1.2.5
|
||||||
app-root-path: 3.1.0
|
app-root-path: 3.1.0
|
||||||
@@ -20707,15 +20726,15 @@ snapshots:
|
|||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
cli-highlight: 2.1.11
|
cli-highlight: 2.1.11
|
||||||
date-fns: 2.29.3
|
date-fns: 2.29.3
|
||||||
debug: 4.3.5
|
debug: 4.4.0(supports-color@5.5.0)
|
||||||
dotenv: 16.4.5
|
dotenv: 16.4.5
|
||||||
glob: 7.2.3
|
glob: 8.1.0
|
||||||
js-yaml: 4.1.0
|
js-yaml: 4.1.0
|
||||||
mkdirp: 1.0.4
|
mkdirp: 2.1.6
|
||||||
reflect-metadata: 0.1.13
|
reflect-metadata: 0.1.13
|
||||||
sha.js: 2.4.11
|
sha.js: 2.4.11
|
||||||
tslib: 2.6.3
|
tslib: 2.8.1
|
||||||
uuid: 8.3.2
|
uuid: 9.0.1
|
||||||
xml2js: 0.4.23
|
xml2js: 0.4.23
|
||||||
yargs: 17.7.2
|
yargs: 17.7.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@@ -20884,8 +20903,7 @@ snapshots:
|
|||||||
|
|
||||||
uuid@8.3.2: {}
|
uuid@8.3.2: {}
|
||||||
|
|
||||||
uuid@9.0.1:
|
uuid@9.0.1: {}
|
||||||
optional: true
|
|
||||||
|
|
||||||
uvu@0.5.6:
|
uvu@0.5.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ class ExternalAPI {
|
|||||||
...options.headers,
|
...options.headers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.axios.interceptors.request = axios.interceptors.request;
|
||||||
|
this.axios.interceptors.response = axios.interceptors.response;
|
||||||
|
|
||||||
if (options.rateLimit) {
|
if (options.rateLimit) {
|
||||||
this.axios = rateLimit(this.axios, {
|
this.axios = rateLimit(this.axios, {
|
||||||
|
|||||||
@@ -22,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;
|
||||||
@@ -113,9 +130,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
const safeDeviceId =
|
const safeDeviceId =
|
||||||
deviceId && deviceId.length > 0
|
deviceId && deviceId.length > 0
|
||||||
? deviceId
|
? deviceId
|
||||||
: Buffer.from(`BOT_jellyseerr_fallback_${Date.now()}`).toString(
|
: Buffer.from('BOT_jellyseerr').toString('base64');
|
||||||
'base64'
|
|
||||||
);
|
|
||||||
|
|
||||||
let authHeaderVal: string;
|
let authHeaderVal: string;
|
||||||
if (authToken) {
|
if (authToken) {
|
||||||
|
|||||||
@@ -367,12 +367,12 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
|
|
||||||
public async pingToken() {
|
public async pingToken() {
|
||||||
try {
|
try {
|
||||||
const data: { pong: unknown } = await this.get('/api/v2/ping', {
|
const response = await this.axios.get('/api/v2/ping', {
|
||||||
headers: {
|
headers: {
|
||||||
'X-Plex-Client-Identifier': randomUUID(),
|
'X-Plex-Client-Identifier': randomUUID(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!data?.pong) {
|
if (!response?.data?.pong) {
|
||||||
throw new Error('No pong response');
|
throw new Error('No pong response');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ class TautulliAPI {
|
|||||||
}${settings.urlBase ?? ''}`,
|
}${settings.urlBase ?? ''}`,
|
||||||
params: { apikey: settings.apiKey },
|
params: { apikey: settings.apiKey },
|
||||||
});
|
});
|
||||||
|
this.axios.interceptors.request = axios.interceptors.request;
|
||||||
|
this.axios.interceptors.response = axios.interceptors.response;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getInfo(): Promise<TautulliInfo> {
|
public async getInfo(): Promise<TautulliInfo> {
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ export const SortOptionsIterable = [
|
|||||||
|
|
||||||
export type SortOptions = (typeof SortOptionsIterable)[number];
|
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;
|
||||||
includeAdult?: boolean;
|
includeAdult?: boolean;
|
||||||
@@ -78,6 +88,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 {
|
||||||
@@ -100,6 +114,10 @@ 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 {
|
||||||
@@ -477,6 +495,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(
|
||||||
@@ -523,6 +545,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
'vote_count.lte': voteCountLte,
|
'vote_count.lte': voteCountLte,
|
||||||
watch_region: watchRegion,
|
watch_region: watchRegion,
|
||||||
with_watch_providers: watchProviders,
|
with_watch_providers: watchProviders,
|
||||||
|
certification: certification,
|
||||||
|
'certification.gte': certificationGte,
|
||||||
|
'certification.lte': certificationLte,
|
||||||
|
certification_country: certificationCountry,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -552,6 +578,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(
|
||||||
@@ -599,6 +629,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -987,6 +1021,35 @@ 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,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import dataSource from '@server/datasource';
|
|||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
|
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
|
||||||
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
import type { EntityManager } from 'typeorm';
|
import type { EntityManager } from 'typeorm';
|
||||||
import {
|
import {
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
Entity,
|
||||||
Index,
|
Index,
|
||||||
JoinColumn,
|
JoinColumn,
|
||||||
@@ -47,7 +47,7 @@ export class Blacklist implements BlacklistItem {
|
|||||||
@Column({ nullable: true, type: 'varchar' })
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
public blacklistedTags?: string;
|
public blacklistedTags?: string;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
constructor(init?: Partial<Blacklist>) {
|
constructor(init?: Partial<Blacklist>) {
|
||||||
|
|||||||
@@ -2,13 +2,8 @@ import type { DiscoverSliderType } from '@server/constants/discover';
|
|||||||
import { defaultSliders } from '@server/constants/discover';
|
import { defaultSliders } from '@server/constants/discover';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import {
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
Column,
|
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
class DiscoverSlider {
|
class DiscoverSlider {
|
||||||
@@ -55,10 +50,14 @@ class DiscoverSlider {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public data?: string;
|
public data?: string;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn()
|
@DbAwareColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
onUpdate: 'CURRENT_TIMESTAMP',
|
||||||
|
})
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
constructor(init?: Partial<DiscoverSlider>) {
|
constructor(init?: Partial<DiscoverSlider>) {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { IssueType } from '@server/constants/issue';
|
import type { IssueType } from '@server/constants/issue';
|
||||||
import { IssueStatus } from '@server/constants/issue';
|
import { IssueStatus } from '@server/constants/issue';
|
||||||
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
import {
|
import {
|
||||||
|
AfterLoad,
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
Entity,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
OneToMany,
|
OneToMany,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import IssueComment from './IssueComment';
|
import IssueComment from './IssueComment';
|
||||||
import Media from './Media';
|
import Media from './Media';
|
||||||
@@ -55,12 +55,21 @@ class Issue {
|
|||||||
})
|
})
|
||||||
public comments: IssueComment[];
|
public comments: IssueComment[];
|
||||||
|
|
||||||
@CreateDateColumn()
|
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn()
|
@DbAwareColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
onUpdate: 'CURRENT_TIMESTAMP',
|
||||||
|
})
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
|
@AfterLoad()
|
||||||
|
sortComments() {
|
||||||
|
this.comments?.sort((a, b) => a.id - b.id);
|
||||||
|
}
|
||||||
|
|
||||||
constructor(init?: Partial<Issue>) {
|
constructor(init?: Partial<Issue>) {
|
||||||
Object.assign(this, init);
|
Object.assign(this, init);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import {
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
Column,
|
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
|
||||||
ManyToOne,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
|
||||||
import Issue from './Issue';
|
import Issue from './Issue';
|
||||||
import { User } from './User';
|
import { User } from './User';
|
||||||
|
|
||||||
@@ -28,10 +22,14 @@ class IssueComment {
|
|||||||
@Column({ type: 'text' })
|
@Column({ type: 'text' })
|
||||||
public message: string;
|
public message: string;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn()
|
@DbAwareColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
onUpdate: 'CURRENT_TIMESTAMP',
|
||||||
|
})
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
constructor(init?: Partial<IssueComment>) {
|
constructor(init?: Partial<IssueComment>) {
|
||||||
|
|||||||
@@ -15,13 +15,11 @@ import { getHostname } from '@server/utils/getHostname';
|
|||||||
import {
|
import {
|
||||||
AfterLoad,
|
AfterLoad,
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
Entity,
|
||||||
Index,
|
Index,
|
||||||
OneToMany,
|
OneToMany,
|
||||||
OneToOne,
|
OneToOne,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import Issue from './Issue';
|
import Issue from './Issue';
|
||||||
import { MediaRequest } from './MediaRequest';
|
import { MediaRequest } from './MediaRequest';
|
||||||
@@ -108,7 +106,9 @@ class Media {
|
|||||||
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
|
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
|
||||||
public status4k: MediaStatus;
|
public status4k: MediaStatus;
|
||||||
|
|
||||||
@OneToMany(() => MediaRequest, (request) => request.media, { cascade: true })
|
@OneToMany(() => MediaRequest, (request) => request.media, {
|
||||||
|
cascade: ['insert', 'remove'],
|
||||||
|
})
|
||||||
public requests: MediaRequest[];
|
public requests: MediaRequest[];
|
||||||
|
|
||||||
@OneToMany(() => Watchlist, (watchlist) => watchlist.media)
|
@OneToMany(() => Watchlist, (watchlist) => watchlist.media)
|
||||||
@@ -126,10 +126,14 @@ class Media {
|
|||||||
@OneToOne(() => Blacklist, (blacklist) => blacklist.media)
|
@OneToOne(() => Blacklist, (blacklist) => blacklist.media)
|
||||||
public blacklist: Promise<Blacklist>;
|
public blacklist: Promise<Blacklist>;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn()
|
@DbAwareColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
onUpdate: 'CURRENT_TIMESTAMP',
|
||||||
|
})
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
import type { RadarrMovieOptions } from '@server/api/servarr/radarr';
|
|
||||||
import RadarrAPI from '@server/api/servarr/radarr';
|
|
||||||
import type {
|
|
||||||
AddSeriesOptions,
|
|
||||||
SonarrSeries,
|
|
||||||
} from '@server/api/servarr/sonarr';
|
|
||||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||||
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
||||||
@@ -20,19 +13,18 @@ import notificationManager, { Notification } from '@server/lib/notifications';
|
|||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { isEqual, truncate } from 'lodash';
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
|
import { truncate } from 'lodash';
|
||||||
import {
|
import {
|
||||||
AfterInsert,
|
AfterInsert,
|
||||||
AfterRemove,
|
AfterLoad,
|
||||||
AfterUpdate,
|
AfterUpdate,
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
Entity,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
OneToMany,
|
OneToMany,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
RelationCount,
|
RelationCount,
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import Media from './Media';
|
import Media from './Media';
|
||||||
import SeasonRequest from './SeasonRequest';
|
import SeasonRequest from './SeasonRequest';
|
||||||
@@ -181,7 +173,8 @@ export class MediaRequest {
|
|||||||
// If there is an existing movie request that isn't declined, don't allow a new one.
|
// If there is an existing movie request that isn't declined, don't allow a new one.
|
||||||
if (
|
if (
|
||||||
requestBody.mediaType === MediaType.MOVIE &&
|
requestBody.mediaType === MediaType.MOVIE &&
|
||||||
existing[0].status !== MediaRequestStatus.DECLINED
|
existing[0].status !== MediaRequestStatus.DECLINED &&
|
||||||
|
existing[0].status !== MediaRequestStatus.COMPLETED
|
||||||
) {
|
) {
|
||||||
logger.warn('Duplicate request for media blocked', {
|
logger.warn('Duplicate request for media blocked', {
|
||||||
tmdbId: tmdbMedia.id,
|
tmdbId: tmdbMedia.id,
|
||||||
@@ -388,7 +381,9 @@ export class MediaRequest {
|
|||||||
>;
|
>;
|
||||||
let requestedSeasons =
|
let requestedSeasons =
|
||||||
requestBody.seasons === 'all'
|
requestBody.seasons === 'all'
|
||||||
? tmdbMediaShow.seasons.map((season) => season.season_number)
|
? tmdbMediaShow.seasons
|
||||||
|
.filter((season) => season.season_number !== 0)
|
||||||
|
.map((season) => season.season_number)
|
||||||
: (requestBody.seasons as number[]);
|
: (requestBody.seasons as number[]);
|
||||||
if (!settings.main.enableSpecialEpisodes) {
|
if (!settings.main.enableSpecialEpisodes) {
|
||||||
requestedSeasons = requestedSeasons.filter((sn) => sn > 0);
|
requestedSeasons = requestedSeasons.filter((sn) => sn > 0);
|
||||||
@@ -404,7 +399,8 @@ export class MediaRequest {
|
|||||||
.filter(
|
.filter(
|
||||||
(request) =>
|
(request) =>
|
||||||
request.is4k === requestBody.is4k &&
|
request.is4k === requestBody.is4k &&
|
||||||
request.status !== MediaRequestStatus.DECLINED
|
request.status !== MediaRequestStatus.DECLINED &&
|
||||||
|
request.status !== MediaRequestStatus.COMPLETED
|
||||||
)
|
)
|
||||||
.reduce((seasons, request) => {
|
.reduce((seasons, request) => {
|
||||||
const combinedSeasons = request.seasons.map(
|
const combinedSeasons = request.seasons.map(
|
||||||
@@ -423,7 +419,9 @@ export class MediaRequest {
|
|||||||
.filter(
|
.filter(
|
||||||
(season) =>
|
(season) =>
|
||||||
season[requestBody.is4k ? 'status4k' : 'status'] !==
|
season[requestBody.is4k ? 'status4k' : 'status'] !==
|
||||||
MediaStatus.UNKNOWN
|
MediaStatus.UNKNOWN &&
|
||||||
|
season[requestBody.is4k ? 'status4k' : 'status'] !==
|
||||||
|
MediaStatus.DELETED
|
||||||
)
|
)
|
||||||
.map((season) => season.seasonNumber),
|
.map((season) => season.seasonNumber),
|
||||||
];
|
];
|
||||||
@@ -537,10 +535,14 @@ export class MediaRequest {
|
|||||||
})
|
})
|
||||||
public modifiedBy?: User;
|
public modifiedBy?: User;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn()
|
@DbAwareColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
onUpdate: 'CURRENT_TIMESTAMP',
|
||||||
|
})
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'varchar' })
|
@Column({ type: 'varchar' })
|
||||||
@@ -608,12 +610,6 @@ export class MediaRequest {
|
|||||||
Object.assign(this, init);
|
Object.assign(this, init);
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterUpdate()
|
|
||||||
@AfterInsert()
|
|
||||||
public async sendMedia(): Promise<void> {
|
|
||||||
await Promise.all([this.sendToRadarr(), this.sendToSonarr()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterInsert()
|
@AfterInsert()
|
||||||
public async notifyNewRequest(): Promise<void> {
|
public async notifyNewRequest(): Promise<void> {
|
||||||
if (this.status === MediaRequestStatus.PENDING) {
|
if (this.status === MediaRequestStatus.PENDING) {
|
||||||
@@ -630,10 +626,14 @@ export class MediaRequest {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendNotification(media, Notification.MEDIA_PENDING);
|
MediaRequest.sendNotification(this, media, Notification.MEDIA_PENDING);
|
||||||
|
|
||||||
if (this.isAutoRequest) {
|
if (this.isAutoRequest) {
|
||||||
this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED);
|
MediaRequest.sendNotification(
|
||||||
|
this,
|
||||||
|
media,
|
||||||
|
Notification.MEDIA_AUTO_REQUESTED
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -671,7 +671,8 @@ export class MediaRequest {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendNotification(
|
MediaRequest.sendNotification(
|
||||||
|
this,
|
||||||
media,
|
media,
|
||||||
this.status === MediaRequestStatus.APPROVED
|
this.status === MediaRequestStatus.APPROVED
|
||||||
? autoApproved
|
? autoApproved
|
||||||
@@ -685,7 +686,11 @@ export class MediaRequest {
|
|||||||
autoApproved &&
|
autoApproved &&
|
||||||
this.isAutoRequest
|
this.isAutoRequest
|
||||||
) {
|
) {
|
||||||
this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED);
|
MediaRequest.sendNotification(
|
||||||
|
this,
|
||||||
|
media,
|
||||||
|
Notification.MEDIA_AUTO_REQUESTED
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -697,699 +702,63 @@ export class MediaRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterUpdate()
|
@AfterLoad()
|
||||||
@AfterInsert()
|
private sortSeasons() {
|
||||||
public async updateParentStatus(): Promise<void> {
|
if (Array.isArray(this.seasons)) {
|
||||||
const mediaRepository = getRepository(Media);
|
this.seasons.sort((a, b) => a.id - b.id);
|
||||||
const media = await mediaRepository.findOne({
|
|
||||||
where: { id: this.media.id },
|
|
||||||
relations: { requests: true },
|
|
||||||
});
|
|
||||||
if (!media) {
|
|
||||||
logger.error('Media data not found', {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const seasonRequestRepository = getRepository(SeasonRequest);
|
|
||||||
if (
|
|
||||||
this.status === MediaRequestStatus.APPROVED &&
|
|
||||||
// Do not update the status if the item is already partially available or available
|
|
||||||
media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE &&
|
|
||||||
media[this.is4k ? 'status4k' : 'status'] !==
|
|
||||||
MediaStatus.PARTIALLY_AVAILABLE &&
|
|
||||||
media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.PROCESSING
|
|
||||||
) {
|
|
||||||
const statusField = this.is4k ? 'status4k' : 'status';
|
|
||||||
|
|
||||||
await mediaRepository.update(
|
|
||||||
{ id: this.media.id },
|
|
||||||
{ [statusField]: MediaStatus.PROCESSING }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
media.mediaType === MediaType.MOVIE &&
|
|
||||||
this.status === MediaRequestStatus.DECLINED
|
|
||||||
) {
|
|
||||||
const statusField = this.is4k ? 'status4k' : 'status';
|
|
||||||
await mediaRepository.update(
|
|
||||||
{ id: this.media.id },
|
|
||||||
{ [statusField]: MediaStatus.UNKNOWN }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If the media type is TV, and we are declining a request,
|
|
||||||
* we must check if its the only pending request and that
|
|
||||||
* there the current media status is just pending (meaning no
|
|
||||||
* other requests have yet to be approved)
|
|
||||||
*/
|
|
||||||
if (
|
|
||||||
media.mediaType === MediaType.TV &&
|
|
||||||
this.status === MediaRequestStatus.DECLINED &&
|
|
||||||
media.requests.filter(
|
|
||||||
(request) => request.status === MediaRequestStatus.PENDING
|
|
||||||
).length === 0 &&
|
|
||||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING
|
|
||||||
) {
|
|
||||||
const statusField = this.is4k ? 'status4k' : 'status';
|
|
||||||
mediaRepository.update(
|
|
||||||
{ id: this.media.id },
|
|
||||||
{ [statusField]: MediaStatus.UNKNOWN }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Approve child seasons if parent is approved
|
|
||||||
if (
|
|
||||||
media.mediaType === MediaType.TV &&
|
|
||||||
this.status === MediaRequestStatus.APPROVED
|
|
||||||
) {
|
|
||||||
this.seasons.forEach((season) => {
|
|
||||||
season.status = MediaRequestStatus.APPROVED;
|
|
||||||
seasonRequestRepository.save(season);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterRemove()
|
static async sendNotification(
|
||||||
public async handleRemoveParentUpdate(): Promise<void> {
|
entity: MediaRequest,
|
||||||
const mediaRepository = getRepository(Media);
|
media: Media,
|
||||||
const fullMedia = await mediaRepository.findOneOrFail({
|
type: Notification
|
||||||
where: { id: this.media.id },
|
) {
|
||||||
relations: { requests: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
!fullMedia.requests.some((request) => !request.is4k) &&
|
|
||||||
fullMedia.status !== MediaStatus.AVAILABLE
|
|
||||||
) {
|
|
||||||
fullMedia.status = MediaStatus.UNKNOWN;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!fullMedia.requests.some((request) => request.is4k) &&
|
|
||||||
fullMedia.status4k !== MediaStatus.AVAILABLE
|
|
||||||
) {
|
|
||||||
fullMedia.status4k = MediaStatus.UNKNOWN;
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaRepository.save(fullMedia);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async sendToRadarr(): Promise<void> {
|
|
||||||
if (
|
|
||||||
this.status === MediaRequestStatus.APPROVED &&
|
|
||||||
this.type === MediaType.MOVIE
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const mediaRepository = getRepository(Media);
|
|
||||||
const settings = getSettings();
|
|
||||||
if (settings.radarr.length === 0 && !settings.radarr[0]) {
|
|
||||||
logger.info(
|
|
||||||
'No Radarr server configured, skipping request processing',
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let radarrSettings = settings.radarr.find(
|
|
||||||
(radarr) => radarr.isDefault && radarr.is4k === this.is4k
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.serverId !== null &&
|
|
||||||
this.serverId >= 0 &&
|
|
||||||
radarrSettings?.id !== this.serverId
|
|
||||||
) {
|
|
||||||
radarrSettings = settings.radarr.find(
|
|
||||||
(radarr) => radarr.id === this.serverId
|
|
||||||
);
|
|
||||||
logger.info(
|
|
||||||
`Request has an override server: ${radarrSettings?.name}`,
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!radarrSettings) {
|
|
||||||
logger.warn(
|
|
||||||
`There is no default ${
|
|
||||||
this.is4k ? '4K ' : ''
|
|
||||||
}Radarr server configured. Did you set any of your ${
|
|
||||||
this.is4k ? '4K ' : ''
|
|
||||||
}Radarr servers as default?`,
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let rootFolder = radarrSettings.activeDirectory;
|
|
||||||
let qualityProfile = radarrSettings.activeProfileId;
|
|
||||||
let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.rootFolder &&
|
|
||||||
this.rootFolder !== '' &&
|
|
||||||
this.rootFolder !== radarrSettings.activeDirectory
|
|
||||||
) {
|
|
||||||
rootFolder = this.rootFolder;
|
|
||||||
logger.info(`Request has an override root folder: ${rootFolder}`, {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.profileId &&
|
|
||||||
this.profileId !== radarrSettings.activeProfileId
|
|
||||||
) {
|
|
||||||
qualityProfile = this.profileId;
|
|
||||||
logger.info(
|
|
||||||
`Request has an override quality profile ID: ${qualityProfile}`,
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.tags && !isEqual(this.tags, radarrSettings.tags)) {
|
|
||||||
tags = this.tags;
|
|
||||||
logger.info(`Request has override tags`, {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
tagIds: tags,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const tmdb = new TheMovieDb();
|
|
||||||
const radarr = new RadarrAPI({
|
|
||||||
apiKey: radarrSettings.apiKey,
|
|
||||||
url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'),
|
|
||||||
});
|
|
||||||
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
|
|
||||||
|
|
||||||
const media = await mediaRepository.findOne({
|
|
||||||
where: { id: this.media.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!media) {
|
|
||||||
logger.error('Media data not found', {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (radarrSettings.tagRequests) {
|
|
||||||
let userTag = (await radarr.getTags()).find((v) =>
|
|
||||||
v.label.startsWith(this.requestedBy.id + ' - ')
|
|
||||||
);
|
|
||||||
if (!userTag) {
|
|
||||||
logger.info(`Requester has no active tag. Creating new`, {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
userId: this.requestedBy.id,
|
|
||||||
newTag:
|
|
||||||
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
|
||||||
});
|
|
||||||
userTag = await radarr.createTag({
|
|
||||||
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (userTag.id) {
|
|
||||||
if (!tags?.find((v) => v === userTag?.id)) {
|
|
||||||
tags?.push(userTag.id);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.warn(`Requester has no tag and failed to add one`, {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
userId: this.requestedBy.id,
|
|
||||||
radarrServer: radarrSettings.hostname + ':' + radarrSettings.port,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
|
||||||
) {
|
|
||||||
logger.warn('Media already exists, marking request as APPROVED', {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
|
|
||||||
await requestRepository.update(this.id, {
|
|
||||||
status: MediaRequestStatus.APPROVED,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const radarrMovieOptions: RadarrMovieOptions = {
|
|
||||||
profileId: qualityProfile,
|
|
||||||
qualityProfileId: qualityProfile,
|
|
||||||
rootFolderPath: rootFolder,
|
|
||||||
minimumAvailability: radarrSettings.minimumAvailability,
|
|
||||||
title: movie.title,
|
|
||||||
tmdbId: movie.id,
|
|
||||||
year: Number(movie.release_date.slice(0, 4)),
|
|
||||||
monitored: true,
|
|
||||||
tags,
|
|
||||||
searchNow: !radarrSettings.preventSearch,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run this asynchronously so we don't wait for it on the UI side
|
|
||||||
radarr
|
|
||||||
.addMovie(radarrMovieOptions)
|
|
||||||
.then(async (radarrMovie) => {
|
|
||||||
// We grab media again here to make sure we have the latest version of it
|
|
||||||
const media = await mediaRepository.findOne({
|
|
||||||
where: { id: this.media.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!media) {
|
|
||||||
throw new Error('Media data not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateFields = {
|
|
||||||
[this.is4k ? 'externalServiceId4k' : 'externalServiceId']:
|
|
||||||
radarrMovie.id,
|
|
||||||
[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']:
|
|
||||||
radarrMovie.titleSlug,
|
|
||||||
[this.is4k ? 'serviceId4k' : 'serviceId']: radarrSettings?.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
await mediaRepository.update({ id: this.media.id }, updateFields);
|
|
||||||
})
|
|
||||||
.catch(async () => {
|
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
|
|
||||||
await requestRepository.update(this.id, {
|
|
||||||
status: MediaRequestStatus.FAILED,
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.warn(
|
|
||||||
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
radarrMovieOptions,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.sendNotification(media, Notification.MEDIA_FAILED);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
radarr.clearCache({
|
|
||||||
tmdbId: movie.id,
|
|
||||||
externalId: this.is4k
|
|
||||||
? media.externalServiceId4k
|
|
||||||
: media.externalServiceId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
logger.info('Sent request to Radarr', {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('Something went wrong sending request to Radarr', {
|
|
||||||
label: 'Media Request',
|
|
||||||
errorMessage: e.message,
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
throw new Error(e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async sendToSonarr(): Promise<void> {
|
|
||||||
if (
|
|
||||||
this.status === MediaRequestStatus.APPROVED &&
|
|
||||||
this.type === MediaType.TV
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const mediaRepository = getRepository(Media);
|
|
||||||
const settings = getSettings();
|
|
||||||
if (settings.sonarr.length === 0 && !settings.sonarr[0]) {
|
|
||||||
logger.warn(
|
|
||||||
'No Sonarr server configured, skipping request processing',
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sonarrSettings = settings.sonarr.find(
|
|
||||||
(sonarr) => sonarr.isDefault && sonarr.is4k === this.is4k
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.serverId !== null &&
|
|
||||||
this.serverId >= 0 &&
|
|
||||||
sonarrSettings?.id !== this.serverId
|
|
||||||
) {
|
|
||||||
sonarrSettings = settings.sonarr.find(
|
|
||||||
(sonarr) => sonarr.id === this.serverId
|
|
||||||
);
|
|
||||||
logger.info(
|
|
||||||
`Request has an override server: ${sonarrSettings?.name}`,
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sonarrSettings) {
|
|
||||||
logger.warn(
|
|
||||||
`There is no default ${
|
|
||||||
this.is4k ? '4K ' : ''
|
|
||||||
}Sonarr server configured. Did you set any of your ${
|
|
||||||
this.is4k ? '4K ' : ''
|
|
||||||
}Sonarr servers as default?`,
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const media = await mediaRepository.findOne({
|
|
||||||
where: { id: this.media.id },
|
|
||||||
relations: { requests: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!media) {
|
|
||||||
throw new Error('Media data not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
|
||||||
) {
|
|
||||||
logger.warn('Media already exists, marking request as APPROVED', {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
await requestRepository.update(this.id, {
|
|
||||||
status: MediaRequestStatus.APPROVED,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tmdb = new TheMovieDb();
|
|
||||||
const sonarr = new SonarrAPI({
|
|
||||||
apiKey: sonarrSettings.apiKey,
|
|
||||||
url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
|
|
||||||
});
|
|
||||||
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
|
|
||||||
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
|
|
||||||
|
|
||||||
if (!tvdbId) {
|
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
await mediaRepository.remove(media);
|
|
||||||
await requestRepository.remove(this);
|
|
||||||
throw new Error('TVDB ID not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
let seriesType: SonarrSeries['seriesType'] = 'standard';
|
|
||||||
|
|
||||||
// Change series type to anime if the anime keyword is present on tmdb
|
|
||||||
if (
|
|
||||||
series.keywords.results.some(
|
|
||||||
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
seriesType = sonarrSettings.animeSeriesType ?? 'anime';
|
|
||||||
}
|
|
||||||
|
|
||||||
let rootFolder =
|
|
||||||
seriesType === 'anime' && sonarrSettings.activeAnimeDirectory
|
|
||||||
? sonarrSettings.activeAnimeDirectory
|
|
||||||
: sonarrSettings.activeDirectory;
|
|
||||||
let qualityProfile =
|
|
||||||
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
|
|
||||||
? sonarrSettings.activeAnimeProfileId
|
|
||||||
: sonarrSettings.activeProfileId;
|
|
||||||
let languageProfile =
|
|
||||||
seriesType === 'anime' && sonarrSettings.activeAnimeLanguageProfileId
|
|
||||||
? sonarrSettings.activeAnimeLanguageProfileId
|
|
||||||
: sonarrSettings.activeLanguageProfileId;
|
|
||||||
let tags =
|
|
||||||
seriesType === 'anime'
|
|
||||||
? sonarrSettings.animeTags
|
|
||||||
? [...sonarrSettings.animeTags]
|
|
||||||
: []
|
|
||||||
: sonarrSettings.tags
|
|
||||||
? [...sonarrSettings.tags]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.rootFolder &&
|
|
||||||
this.rootFolder !== '' &&
|
|
||||||
this.rootFolder !== rootFolder
|
|
||||||
) {
|
|
||||||
rootFolder = this.rootFolder;
|
|
||||||
logger.info(`Request has an override root folder: ${rootFolder}`, {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.profileId && this.profileId !== qualityProfile) {
|
|
||||||
qualityProfile = this.profileId;
|
|
||||||
logger.info(
|
|
||||||
`Request has an override quality profile ID: ${qualityProfile}`,
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.languageProfileId &&
|
|
||||||
this.languageProfileId !== languageProfile
|
|
||||||
) {
|
|
||||||
languageProfile = this.languageProfileId;
|
|
||||||
logger.info(
|
|
||||||
`Request has an override language profile ID: ${languageProfile}`,
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.tags && !isEqual(this.tags, tags)) {
|
|
||||||
tags = this.tags;
|
|
||||||
logger.info(`Request has override tags`, {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
tagIds: tags,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sonarrSettings.tagRequests) {
|
|
||||||
let userTag = (await sonarr.getTags()).find((v) =>
|
|
||||||
v.label.startsWith(this.requestedBy.id + ' - ')
|
|
||||||
);
|
|
||||||
if (!userTag) {
|
|
||||||
logger.info(`Requester has no active tag. Creating new`, {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
userId: this.requestedBy.id,
|
|
||||||
newTag:
|
|
||||||
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
|
||||||
});
|
|
||||||
userTag = await sonarr.createTag({
|
|
||||||
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (userTag.id) {
|
|
||||||
if (!tags?.find((v) => v === userTag?.id)) {
|
|
||||||
tags?.push(userTag.id);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.warn(`Requester has no tag and failed to add one`, {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
userId: this.requestedBy.id,
|
|
||||||
sonarrServer: sonarrSettings.hostname + ':' + sonarrSettings.port,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sonarrSeriesOptions: AddSeriesOptions = {
|
|
||||||
profileId: qualityProfile,
|
|
||||||
languageProfileId: languageProfile,
|
|
||||||
rootFolderPath: rootFolder,
|
|
||||||
title: series.name,
|
|
||||||
tvdbid: tvdbId,
|
|
||||||
seasons: this.seasons.map((season) => season.seasonNumber),
|
|
||||||
seasonFolder: sonarrSettings.enableSeasonFolders,
|
|
||||||
seriesType,
|
|
||||||
tags,
|
|
||||||
monitored: true,
|
|
||||||
searchNow: !sonarrSettings.preventSearch,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run this asynchronously so we don't wait for it on the UI side
|
|
||||||
sonarr
|
|
||||||
.addSeries(sonarrSeriesOptions)
|
|
||||||
.then(async (sonarrSeries) => {
|
|
||||||
// We grab media again here to make sure we have the latest version of it
|
|
||||||
const media = await mediaRepository.findOne({
|
|
||||||
where: { id: this.media.id },
|
|
||||||
relations: { requests: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!media) {
|
|
||||||
throw new Error('Media data not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateFields = {
|
|
||||||
[this.is4k ? 'externalServiceId4k' : 'externalServiceId']:
|
|
||||||
sonarrSeries.id,
|
|
||||||
[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']:
|
|
||||||
sonarrSeries.titleSlug,
|
|
||||||
[this.is4k ? 'serviceId4k' : 'serviceId']: sonarrSettings?.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
await mediaRepository.update({ id: this.media.id }, updateFields);
|
|
||||||
})
|
|
||||||
.catch(async () => {
|
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
|
|
||||||
await requestRepository.update(
|
|
||||||
{ id: this.id },
|
|
||||||
{ status: MediaRequestStatus.FAILED }
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.warn(
|
|
||||||
'Something went wrong sending series request to Sonarr, marking status as FAILED',
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
sonarrSeriesOptions,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.sendNotification(media, Notification.MEDIA_FAILED);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
sonarr.clearCache({
|
|
||||||
tvdbId,
|
|
||||||
externalId: this.is4k
|
|
||||||
? media.externalServiceId4k
|
|
||||||
: media.externalServiceId,
|
|
||||||
title: series.name,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
logger.info('Sent request to Sonarr', {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('Something went wrong sending request to Sonarr', {
|
|
||||||
label: 'Media Request',
|
|
||||||
errorMessage: e.message,
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
throw new Error(e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendNotification(media: Media, type: Notification) {
|
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series';
|
const mediaType = entity.type === MediaType.MOVIE ? 'Movie' : 'Series';
|
||||||
let event: string | undefined;
|
let event: string | undefined;
|
||||||
let notifyAdmin = true;
|
let notifyAdmin = true;
|
||||||
let notifySystem = true;
|
let notifySystem = true;
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case Notification.MEDIA_APPROVED:
|
case Notification.MEDIA_APPROVED:
|
||||||
event = `${this.is4k ? '4K ' : ''}${mediaType} Request Approved`;
|
event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Approved`;
|
||||||
notifyAdmin = false;
|
notifyAdmin = false;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_DECLINED:
|
case Notification.MEDIA_DECLINED:
|
||||||
event = `${this.is4k ? '4K ' : ''}${mediaType} Request Declined`;
|
event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Declined`;
|
||||||
notifyAdmin = false;
|
notifyAdmin = false;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_PENDING:
|
case Notification.MEDIA_PENDING:
|
||||||
event = `New ${this.is4k ? '4K ' : ''}${mediaType} Request`;
|
event = `New ${entity.is4k ? '4K ' : ''}${mediaType} Request`;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_AUTO_REQUESTED:
|
case Notification.MEDIA_AUTO_REQUESTED:
|
||||||
event = `${
|
event = `${
|
||||||
this.is4k ? '4K ' : ''
|
entity.is4k ? '4K ' : ''
|
||||||
}${mediaType} Request Automatically Submitted`;
|
}${mediaType} Request Automatically Submitted`;
|
||||||
notifyAdmin = false;
|
notifyAdmin = false;
|
||||||
notifySystem = false;
|
notifySystem = false;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_AUTO_APPROVED:
|
case Notification.MEDIA_AUTO_APPROVED:
|
||||||
event = `${
|
event = `${
|
||||||
this.is4k ? '4K ' : ''
|
entity.is4k ? '4K ' : ''
|
||||||
}${mediaType} Request Automatically Approved`;
|
}${mediaType} Request Automatically Approved`;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_FAILED:
|
case Notification.MEDIA_FAILED:
|
||||||
event = `${this.is4k ? '4K ' : ''}${mediaType} Request Failed`;
|
event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Failed`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.type === MediaType.MOVIE) {
|
if (entity.type === MediaType.MOVIE) {
|
||||||
const movie = await tmdb.getMovie({ movieId: media.tmdbId });
|
const movie = await tmdb.getMovie({ movieId: media.tmdbId });
|
||||||
notificationManager.sendNotification(type, {
|
notificationManager.sendNotification(type, {
|
||||||
media,
|
media,
|
||||||
request: this,
|
request: entity,
|
||||||
notifyAdmin,
|
notifyAdmin,
|
||||||
notifySystem,
|
notifySystem,
|
||||||
notifyUser: notifyAdmin ? undefined : this.requestedBy,
|
notifyUser: notifyAdmin ? undefined : entity.requestedBy,
|
||||||
event,
|
event,
|
||||||
subject: `${movie.title}${
|
subject: `${movie.title}${
|
||||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||||
@@ -1401,14 +770,14 @@ export class MediaRequest {
|
|||||||
}),
|
}),
|
||||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||||
});
|
});
|
||||||
} else if (this.type === MediaType.TV) {
|
} else if (entity.type === MediaType.TV) {
|
||||||
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
|
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||||
notificationManager.sendNotification(type, {
|
notificationManager.sendNotification(type, {
|
||||||
media,
|
media,
|
||||||
request: this,
|
request: entity,
|
||||||
notifyAdmin,
|
notifyAdmin,
|
||||||
notifySystem,
|
notifySystem,
|
||||||
notifyUser: notifyAdmin ? undefined : this.requestedBy,
|
notifyUser: notifyAdmin ? undefined : entity.requestedBy,
|
||||||
event,
|
event,
|
||||||
subject: `${tv.name}${
|
subject: `${tv.name}${
|
||||||
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
||||||
@@ -1422,7 +791,7 @@ export class MediaRequest {
|
|||||||
extra: [
|
extra: [
|
||||||
{
|
{
|
||||||
name: 'Requested Seasons',
|
name: 'Requested Seasons',
|
||||||
value: this.seasons
|
value: entity.seasons
|
||||||
.map((season) => season.seasonNumber)
|
.map((season) => season.seasonNumber)
|
||||||
.join(', '),
|
.join(', '),
|
||||||
},
|
},
|
||||||
@@ -1433,8 +802,8 @@ export class MediaRequest {
|
|||||||
logger.error('Something went wrong sending media notification(s)', {
|
logger.error('Something went wrong sending media notification(s)', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
requestId: this.id,
|
requestId: entity.id,
|
||||||
mediaId: this.media.id,
|
mediaId: entity.media.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import {
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
Column,
|
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
class OverrideRule {
|
class OverrideRule {
|
||||||
@@ -38,10 +33,14 @@ class OverrideRule {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public tags?: string;
|
public tags?: string;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn()
|
@DbAwareColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
onUpdate: 'CURRENT_TIMESTAMP',
|
||||||
|
})
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
constructor(init?: Partial<OverrideRule>) {
|
constructor(init?: Partial<OverrideRule>) {
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { MediaStatus } from '@server/constants/media';
|
import { MediaStatus } from '@server/constants/media';
|
||||||
import {
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
Column,
|
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
|
||||||
ManyToOne,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
|
||||||
import Media from './Media';
|
import Media from './Media';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
@@ -28,10 +22,14 @@ class Season {
|
|||||||
})
|
})
|
||||||
public media: Promise<Media>;
|
public media: Promise<Media>;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn()
|
@DbAwareColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
onUpdate: 'CURRENT_TIMESTAMP',
|
||||||
|
})
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
constructor(init?: Partial<Season>) {
|
constructor(init?: Partial<Season>) {
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
import { MediaRequestStatus } from '@server/constants/media';
|
import { MediaRequestStatus } from '@server/constants/media';
|
||||||
import { getRepository } from '@server/datasource';
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
import {
|
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
AfterRemove,
|
|
||||||
Column,
|
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
|
||||||
ManyToOne,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
|
||||||
import { MediaRequest } from './MediaRequest';
|
import { MediaRequest } from './MediaRequest';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
@@ -27,27 +19,19 @@ class SeasonRequest {
|
|||||||
})
|
})
|
||||||
public request: MediaRequest;
|
public request: MediaRequest;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn()
|
@DbAwareColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
onUpdate: 'CURRENT_TIMESTAMP',
|
||||||
|
})
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
constructor(init?: Partial<SeasonRequest>) {
|
constructor(init?: Partial<SeasonRequest>) {
|
||||||
Object.assign(this, init);
|
Object.assign(this, init);
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterRemove()
|
|
||||||
public async handleRemoveParent(): Promise<void> {
|
|
||||||
const mediaRequestRepository = getRepository(MediaRequest);
|
|
||||||
const requestToBeDeleted = await mediaRequestRepository.findOneOrFail({
|
|
||||||
where: { id: this.request.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (requestToBeDeleted.seasons.length === 0) {
|
|
||||||
await mediaRequestRepository.delete({ id: this.request.id });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SeasonRequest;
|
export default SeasonRequest;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { hasPermission, Permission } from '@server/lib/permissions';
|
|||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { AfterDate } from '@server/utils/dateHelpers';
|
import { AfterDate } from '@server/utils/dateHelpers';
|
||||||
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -16,14 +17,12 @@ import { default as generatePassword } from 'secure-random-password';
|
|||||||
import {
|
import {
|
||||||
AfterLoad,
|
AfterLoad,
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
Entity,
|
||||||
Not,
|
Not,
|
||||||
OneToMany,
|
OneToMany,
|
||||||
OneToOne,
|
OneToOne,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
RelationCount,
|
RelationCount,
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import Issue from './Issue';
|
import Issue from './Issue';
|
||||||
import { MediaRequest } from './MediaRequest';
|
import { MediaRequest } from './MediaRequest';
|
||||||
@@ -138,10 +137,14 @@ export class User {
|
|||||||
@OneToMany(() => Issue, (issue) => issue.createdBy, { cascade: true })
|
@OneToMany(() => Issue, (issue) => issue.createdBy, { cascade: true })
|
||||||
public createdIssues: Issue[];
|
public createdIssues: Issue[];
|
||||||
|
|
||||||
@CreateDateColumn()
|
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn()
|
@DbAwareColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
onUpdate: 'CURRENT_TIMESTAMP',
|
||||||
|
})
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
public warnings: string[] = [];
|
public warnings: string[] = [];
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import {
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
Column,
|
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
|
||||||
ManyToOne,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
} from 'typeorm';
|
|
||||||
import { User } from './User';
|
import { User } from './User';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
@@ -30,7 +25,11 @@ export class UserPushSubscription {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public userAgent: string;
|
public userAgent: string;
|
||||||
|
|
||||||
@CreateDateColumn({ nullable: true })
|
@DbAwareColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
constructor(init?: Partial<UserPushSubscription>) {
|
constructor(init?: Partial<UserPushSubscription>) {
|
||||||
|
|||||||
@@ -5,15 +5,14 @@ import Media from '@server/entity/Media';
|
|||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
import {
|
import {
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
Entity,
|
||||||
Index,
|
Index,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
Unique,
|
Unique,
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import type { ZodNumber, ZodOptional, ZodString } from 'zod';
|
import type { ZodNumber, ZodOptional, ZodString } from 'zod';
|
||||||
|
|
||||||
@@ -56,10 +55,14 @@ export class Watchlist implements WatchlistItem {
|
|||||||
})
|
})
|
||||||
public media: Media;
|
public media: Media;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn()
|
@DbAwareColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
onUpdate: 'CURRENT_TIMESTAMP',
|
||||||
|
})
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
constructor(init?: Partial<Watchlist>) {
|
constructor(init?: Partial<Watchlist>) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import DiscordAgent from '@server/lib/notifications/agents/discord';
|
|||||||
import EmailAgent from '@server/lib/notifications/agents/email';
|
import EmailAgent from '@server/lib/notifications/agents/email';
|
||||||
import GotifyAgent from '@server/lib/notifications/agents/gotify';
|
import GotifyAgent from '@server/lib/notifications/agents/gotify';
|
||||||
import LunaSeaAgent from '@server/lib/notifications/agents/lunasea';
|
import LunaSeaAgent from '@server/lib/notifications/agents/lunasea';
|
||||||
|
import NtfyAgent from '@server/lib/notifications/agents/ntfy';
|
||||||
import PushbulletAgent from '@server/lib/notifications/agents/pushbullet';
|
import PushbulletAgent from '@server/lib/notifications/agents/pushbullet';
|
||||||
import PushoverAgent from '@server/lib/notifications/agents/pushover';
|
import PushoverAgent from '@server/lib/notifications/agents/pushover';
|
||||||
import SlackAgent from '@server/lib/notifications/agents/slack';
|
import SlackAgent from '@server/lib/notifications/agents/slack';
|
||||||
@@ -27,6 +28,7 @@ import { getAppVersion } from '@server/utils/appVersion';
|
|||||||
import createCustomProxyAgent from '@server/utils/customProxyAgent';
|
import createCustomProxyAgent from '@server/utils/customProxyAgent';
|
||||||
import restartFlag from '@server/utils/restartFlag';
|
import restartFlag from '@server/utils/restartFlag';
|
||||||
import { getClientIp } from '@supercharge/request-ip';
|
import { getClientIp } from '@supercharge/request-ip';
|
||||||
|
import axios from 'axios';
|
||||||
import { TypeormStore } from 'connect-typeorm/out';
|
import { TypeormStore } from 'connect-typeorm/out';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import type { NextFunction, Request, Response } from 'express';
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
@@ -34,6 +36,8 @@ import express from 'express';
|
|||||||
import * as OpenApiValidator from 'express-openapi-validator';
|
import * as OpenApiValidator from 'express-openapi-validator';
|
||||||
import type { Store } from 'express-session';
|
import type { Store } from 'express-session';
|
||||||
import session from 'express-session';
|
import session from 'express-session';
|
||||||
|
import http from 'http';
|
||||||
|
import https from 'https';
|
||||||
import next from 'next';
|
import next from 'next';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import swaggerUi from 'swagger-ui-express';
|
import swaggerUi from 'swagger-ui-express';
|
||||||
@@ -72,6 +76,11 @@ app
|
|||||||
const settings = await getSettings().load();
|
const settings = await getSettings().load();
|
||||||
restartFlag.initializeSettings(settings);
|
restartFlag.initializeSettings(settings);
|
||||||
|
|
||||||
|
if (settings.network.forceIpv4First) {
|
||||||
|
axios.defaults.httpAgent = new http.Agent({ family: 4 });
|
||||||
|
axios.defaults.httpsAgent = new https.Agent({ family: 4 });
|
||||||
|
}
|
||||||
|
|
||||||
// Register HTTP proxy
|
// Register HTTP proxy
|
||||||
if (settings.network.proxy.enabled) {
|
if (settings.network.proxy.enabled) {
|
||||||
await createCustomProxyAgent(settings.network.proxy);
|
await createCustomProxyAgent(settings.network.proxy);
|
||||||
@@ -103,6 +112,7 @@ app
|
|||||||
new DiscordAgent(),
|
new DiscordAgent(),
|
||||||
new EmailAgent(),
|
new EmailAgent(),
|
||||||
new GotifyAgent(),
|
new GotifyAgent(),
|
||||||
|
new NtfyAgent(),
|
||||||
new LunaSeaAgent(),
|
new LunaSeaAgent(),
|
||||||
new PushbulletAgent(),
|
new PushbulletAgent(),
|
||||||
new PushoverAgent(),
|
new PushoverAgent(),
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface PublicSettingsResponse {
|
|||||||
applicationTitle: string;
|
applicationTitle: string;
|
||||||
applicationUrl: string;
|
applicationUrl: string;
|
||||||
hideAvailable: boolean;
|
hideAvailable: boolean;
|
||||||
|
hideBlacklisted: boolean;
|
||||||
localLogin: boolean;
|
localLogin: boolean;
|
||||||
mediaServerLogin: boolean;
|
mediaServerLogin: boolean;
|
||||||
movie4kEnabled: boolean;
|
movie4kEnabled: boolean;
|
||||||
@@ -45,6 +46,7 @@ export interface PublicSettingsResponse {
|
|||||||
locale: string;
|
locale: string;
|
||||||
emailEnabled: boolean;
|
emailEnabled: boolean;
|
||||||
newPlexLogin: boolean;
|
newPlexLogin: boolean;
|
||||||
|
youtubeUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CacheItem {
|
export interface CacheItem {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { getRepository } from '@server/datasource';
|
|||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
import MediaRequest from '@server/entity/MediaRequest';
|
import MediaRequest from '@server/entity/MediaRequest';
|
||||||
import type Season from '@server/entity/Season';
|
import type Season from '@server/entity/Season';
|
||||||
import SeasonRequest from '@server/entity/SeasonRequest';
|
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
@@ -42,7 +41,7 @@ class AvailabilitySync {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info(`Starting availability sync...`, {
|
logger.info(`Starting availability sync...`, {
|
||||||
label: 'AvailabilitySync',
|
label: 'Availability Sync',
|
||||||
});
|
});
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
|
|
||||||
@@ -456,11 +455,11 @@ class AvailabilitySync {
|
|||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
logger.error('Failed to complete availability sync.', {
|
logger.error('Failed to complete availability sync.', {
|
||||||
errorMessage: ex.message,
|
errorMessage: ex.message,
|
||||||
label: 'AvailabilitySync',
|
label: 'Availability Sync',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
logger.info(`Availability sync complete.`, {
|
logger.info(`Availability sync complete.`, {
|
||||||
label: 'AvailabilitySync',
|
label: 'Availability Sync',
|
||||||
});
|
});
|
||||||
this.running = false;
|
this.running = false;
|
||||||
}
|
}
|
||||||
@@ -496,98 +495,66 @@ class AvailabilitySync {
|
|||||||
} while (mediaPage.length > 0);
|
} while (mediaPage.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private findMediaStatus(
|
|
||||||
requests: MediaRequest[],
|
|
||||||
is4k: boolean
|
|
||||||
): MediaStatus {
|
|
||||||
const filteredRequests = requests.filter(
|
|
||||||
(request) => request.is4k === is4k
|
|
||||||
);
|
|
||||||
|
|
||||||
let mediaStatus: MediaStatus;
|
|
||||||
|
|
||||||
if (
|
|
||||||
filteredRequests.some(
|
|
||||||
(request) => request.status === MediaRequestStatus.APPROVED
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
mediaStatus = MediaStatus.PROCESSING;
|
|
||||||
} else if (
|
|
||||||
filteredRequests.some(
|
|
||||||
(request) => request.status === MediaRequestStatus.PENDING
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
mediaStatus = MediaStatus.PENDING;
|
|
||||||
} else {
|
|
||||||
mediaStatus = MediaStatus.UNKNOWN;
|
|
||||||
}
|
|
||||||
|
|
||||||
return mediaStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async mediaUpdater(
|
private async mediaUpdater(
|
||||||
media: Media,
|
media: Media,
|
||||||
is4k: boolean,
|
is4k: boolean,
|
||||||
mediaServerType: MediaServerType
|
mediaServerType: MediaServerType
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find all related requests only if
|
// If media type is tv, check if a season is processing
|
||||||
// the related media has an available status
|
// to see if we need to keep the external metadata
|
||||||
const requests = await requestRepository
|
let isMediaProcessing = false;
|
||||||
.createQueryBuilder('request')
|
|
||||||
.leftJoinAndSelect('request.media', 'media')
|
|
||||||
.where('(media.id = :id)', {
|
|
||||||
id: media.id,
|
|
||||||
})
|
|
||||||
.andWhere(
|
|
||||||
`(request.is4k = :is4k AND media.${
|
|
||||||
is4k ? 'status4k' : 'status'
|
|
||||||
} IN (:...mediaStatus))`,
|
|
||||||
{
|
|
||||||
mediaStatus: [
|
|
||||||
MediaStatus.AVAILABLE,
|
|
||||||
MediaStatus.PARTIALLY_AVAILABLE,
|
|
||||||
],
|
|
||||||
is4k: is4k,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.getMany();
|
|
||||||
|
|
||||||
// Check if a season is processing or pending to
|
|
||||||
// make sure we set the media to the correct status
|
|
||||||
let mediaStatus = MediaStatus.UNKNOWN;
|
|
||||||
|
|
||||||
if (media.mediaType === 'tv') {
|
if (media.mediaType === 'tv') {
|
||||||
mediaStatus = this.findMediaStatus(requests, is4k);
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
|
const request = await requestRepository
|
||||||
|
.createQueryBuilder('request')
|
||||||
|
.leftJoinAndSelect('request.media', 'media')
|
||||||
|
.where('(media.id = :id)', {
|
||||||
|
id: media.id,
|
||||||
|
})
|
||||||
|
.andWhere(
|
||||||
|
'(request.is4k = :is4k AND request.status = :requestStatus)',
|
||||||
|
{
|
||||||
|
requestStatus: MediaRequestStatus.APPROVED,
|
||||||
|
is4k: is4k,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (request) {
|
||||||
|
isMediaProcessing = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
media[is4k ? 'status4k' : 'status'] = mediaStatus;
|
// Set the non-4K or 4K media to deleted
|
||||||
media[is4k ? 'serviceId4k' : 'serviceId'] =
|
// and change related columns to null if media
|
||||||
mediaStatus === MediaStatus.PROCESSING
|
// is not processing
|
||||||
? media[is4k ? 'serviceId4k' : 'serviceId']
|
media[is4k ? 'status4k' : 'status'] = MediaStatus.DELETED;
|
||||||
: null;
|
media[is4k ? 'serviceId4k' : 'serviceId'] = isMediaProcessing
|
||||||
|
? media[is4k ? 'serviceId4k' : 'serviceId']
|
||||||
|
: null;
|
||||||
media[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
media[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||||
mediaStatus === MediaStatus.PROCESSING
|
isMediaProcessing
|
||||||
? media[is4k ? 'externalServiceId4k' : 'externalServiceId']
|
? media[is4k ? 'externalServiceId4k' : 'externalServiceId']
|
||||||
: null;
|
: null;
|
||||||
media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
||||||
mediaStatus === MediaStatus.PROCESSING
|
isMediaProcessing
|
||||||
? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']
|
? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']
|
||||||
: null;
|
: null;
|
||||||
if (mediaServerType === MediaServerType.PLEX) {
|
if (mediaServerType === MediaServerType.PLEX) {
|
||||||
media[is4k ? 'ratingKey4k' : 'ratingKey'] =
|
media[is4k ? 'ratingKey4k' : 'ratingKey'] = isMediaProcessing
|
||||||
mediaStatus === MediaStatus.PROCESSING
|
? media[is4k ? 'ratingKey4k' : 'ratingKey']
|
||||||
? media[is4k ? 'ratingKey4k' : 'ratingKey']
|
: null;
|
||||||
: null;
|
|
||||||
} else if (
|
} else if (
|
||||||
mediaServerType === MediaServerType.JELLYFIN ||
|
mediaServerType === MediaServerType.JELLYFIN ||
|
||||||
mediaServerType === MediaServerType.EMBY
|
mediaServerType === MediaServerType.EMBY
|
||||||
) {
|
) {
|
||||||
media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] =
|
media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] =
|
||||||
mediaStatus === MediaStatus.PROCESSING
|
isMediaProcessing
|
||||||
? media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId']
|
? media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId']
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
@@ -602,18 +569,11 @@ class AvailabilitySync {
|
|||||||
: mediaServerType === MediaServerType.JELLYFIN
|
: mediaServerType === MediaServerType.JELLYFIN
|
||||||
? 'jellyfin'
|
? 'jellyfin'
|
||||||
: 'emby'
|
: 'emby'
|
||||||
} instance. Status will be changed to unknown.`,
|
} instance. Status will be changed to deleted.`,
|
||||||
{ label: 'AvailabilitySync' }
|
{ label: 'AvailabilitySync' }
|
||||||
);
|
);
|
||||||
|
|
||||||
await mediaRepository.save({ media, ...media });
|
await mediaRepository.save(media);
|
||||||
|
|
||||||
// Only delete media request if type is movie.
|
|
||||||
// Type tv request deletion is handled
|
|
||||||
// in the season request entity
|
|
||||||
if (requests.length > 0 && media.mediaType === 'movie') {
|
|
||||||
await requestRepository.remove(requests);
|
|
||||||
}
|
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Failure updating the ${is4k ? '4K' : 'non-4K'} ${
|
`Failure updating the ${is4k ? '4K' : 'non-4K'} ${
|
||||||
@@ -621,7 +581,7 @@ class AvailabilitySync {
|
|||||||
} [TMDB ID ${media.tmdbId}].`,
|
} [TMDB ID ${media.tmdbId}].`,
|
||||||
{
|
{
|
||||||
errorMessage: ex.message,
|
errorMessage: ex.message,
|
||||||
label: 'AvailabilitySync',
|
label: 'Availability Sync',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -634,61 +594,44 @@ class AvailabilitySync {
|
|||||||
mediaServerType: MediaServerType
|
mediaServerType: MediaServerType
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
const seasonRequestRepository = getRepository(SeasonRequest);
|
|
||||||
|
|
||||||
|
// Filter out only the values that are false
|
||||||
|
// (media that should be deleted)
|
||||||
const seasonsPendingRemoval = new Map(
|
const seasonsPendingRemoval = new Map(
|
||||||
// Disabled linter as only the value is needed from the filter
|
// Disabled linter as only the value is needed from the filter
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
[...seasons].filter(([_, exists]) => !exists)
|
[...seasons].filter(([_, exists]) => !exists)
|
||||||
);
|
);
|
||||||
|
// Retrieve the season keys to pass into our log
|
||||||
const seasonKeys = [...seasonsPendingRemoval.keys()];
|
const seasonKeys = [...seasonsPendingRemoval.keys()];
|
||||||
|
|
||||||
// let isSeasonRemoved = false;
|
// let isSeasonRemoved = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Need to check and see if there are any related season
|
|
||||||
// requests. If they are, we will need to delete them.
|
|
||||||
const seasonRequests = await seasonRequestRepository
|
|
||||||
.createQueryBuilder('seasonRequest')
|
|
||||||
.leftJoinAndSelect('seasonRequest.request', 'request')
|
|
||||||
.leftJoinAndSelect('request.media', 'media')
|
|
||||||
.where('(media.id = :id)', { id: media.id })
|
|
||||||
.andWhere(
|
|
||||||
'(request.is4k = :is4k AND seasonRequest.seasonNumber IN (:...seasonNumbers))',
|
|
||||||
{
|
|
||||||
seasonNumbers: seasonKeys,
|
|
||||||
is4k: is4k,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.getMany();
|
|
||||||
|
|
||||||
for (const mediaSeason of media.seasons) {
|
for (const mediaSeason of media.seasons) {
|
||||||
if (seasonsPendingRemoval.has(mediaSeason.seasonNumber)) {
|
if (seasonsPendingRemoval.has(mediaSeason.seasonNumber)) {
|
||||||
mediaSeason[is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
mediaSeason[is4k ? 'status4k' : 'status'] = MediaStatus.DELETED;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (media.status === MediaStatus.AVAILABLE) {
|
if (media.status === MediaStatus.AVAILABLE && !is4k) {
|
||||||
media.status = MediaStatus.PARTIALLY_AVAILABLE;
|
media.status = MediaStatus.PARTIALLY_AVAILABLE;
|
||||||
logger.info(
|
logger.info(
|
||||||
`Marking the non-4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
|
`Marking the non-4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
|
||||||
{ label: 'AvailabilitySync' }
|
{ label: 'Availability Sync' }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (media.status4k === MediaStatus.AVAILABLE) {
|
if (media.status4k === MediaStatus.AVAILABLE && is4k) {
|
||||||
media.status4k = MediaStatus.PARTIALLY_AVAILABLE;
|
media.status4k = MediaStatus.PARTIALLY_AVAILABLE;
|
||||||
logger.info(
|
logger.info(
|
||||||
`Marking the 4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
|
`Marking the 4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
|
||||||
{ label: 'AvailabilitySync' }
|
{ label: 'Availability Sync' }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await mediaRepository.save({ media, ...media });
|
media.lastSeasonChange = new Date();
|
||||||
|
await mediaRepository.save(media);
|
||||||
if (seasonRequests.length > 0) {
|
|
||||||
await seasonRequestRepository.remove(seasonRequests);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`The ${is4k ? '4K' : 'non-4K'} season(s) [${seasonKeys}] [TMDB ID ${
|
`The ${is4k ? '4K' : 'non-4K'} season(s) [${seasonKeys}] [TMDB ID ${
|
||||||
@@ -701,7 +644,7 @@ class AvailabilitySync {
|
|||||||
: mediaServerType === MediaServerType.JELLYFIN
|
: mediaServerType === MediaServerType.JELLYFIN
|
||||||
? 'jellyfin'
|
? 'jellyfin'
|
||||||
: 'emby'
|
: 'emby'
|
||||||
} instance. Status will be changed to unknown.`,
|
} instance. Status will be changed to deleted.`,
|
||||||
{ label: 'AvailabilitySync' }
|
{ label: 'AvailabilitySync' }
|
||||||
);
|
);
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
@@ -711,7 +654,7 @@ class AvailabilitySync {
|
|||||||
} season(s) [${seasonKeys}], TMDB ID ${media.tmdbId}.`,
|
} season(s) [${seasonKeys}], TMDB ID ${media.tmdbId}.`,
|
||||||
{
|
{
|
||||||
errorMessage: ex.message,
|
errorMessage: ex.message,
|
||||||
label: 'AvailabilitySync',
|
label: 'Availability Sync',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -725,7 +668,9 @@ class AvailabilitySync {
|
|||||||
|
|
||||||
// Check for availability in all of the available radarr servers
|
// Check for availability in all of the available radarr servers
|
||||||
// If any find the media, we will assume the media exists
|
// If any find the media, we will assume the media exists
|
||||||
for (const server of this.radarrServers) {
|
for (const server of this.radarrServers.filter(
|
||||||
|
(server) => server.is4k === is4k
|
||||||
|
)) {
|
||||||
const radarrAPI = new RadarrAPI({
|
const radarrAPI = new RadarrAPI({
|
||||||
apiKey: server.apiKey,
|
apiKey: server.apiKey,
|
||||||
url: RadarrAPI.buildUrl(server, '/api/v3'),
|
url: RadarrAPI.buildUrl(server, '/api/v3'),
|
||||||
@@ -734,13 +679,13 @@ class AvailabilitySync {
|
|||||||
try {
|
try {
|
||||||
let radarr: RadarrMovie | undefined;
|
let radarr: RadarrMovie | undefined;
|
||||||
|
|
||||||
if (!server.is4k && media.externalServiceId && !is4k) {
|
if (media.externalServiceId && !is4k) {
|
||||||
radarr = await radarrAPI.getMovie({
|
radarr = await radarrAPI.getMovie({
|
||||||
id: media.externalServiceId,
|
id: media.externalServiceId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (server.is4k && media.externalServiceId4k && is4k) {
|
if (media.externalServiceId4k && is4k) {
|
||||||
radarr = await radarrAPI.getMovie({
|
radarr = await radarrAPI.getMovie({
|
||||||
id: media.externalServiceId4k,
|
id: media.externalServiceId4k,
|
||||||
});
|
});
|
||||||
@@ -762,7 +707,7 @@ class AvailabilitySync {
|
|||||||
}] from Radarr.`,
|
}] from Radarr.`,
|
||||||
{
|
{
|
||||||
errorMessage: ex.message,
|
errorMessage: ex.message,
|
||||||
label: 'AvailabilitySync',
|
label: 'Availability Sync',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -781,7 +726,9 @@ class AvailabilitySync {
|
|||||||
|
|
||||||
// Check for availability in all of the available sonarr servers
|
// Check for availability in all of the available sonarr servers
|
||||||
// If any find the media, we will assume the media exists
|
// If any find the media, we will assume the media exists
|
||||||
for (const server of this.sonarrServers) {
|
for (const server of this.sonarrServers.filter((server) => {
|
||||||
|
return server.is4k === is4k;
|
||||||
|
})) {
|
||||||
const sonarrAPI = new SonarrAPI({
|
const sonarrAPI = new SonarrAPI({
|
||||||
apiKey: server.apiKey,
|
apiKey: server.apiKey,
|
||||||
url: SonarrAPI.buildUrl(server, '/api/v3'),
|
url: SonarrAPI.buildUrl(server, '/api/v3'),
|
||||||
@@ -790,13 +737,13 @@ class AvailabilitySync {
|
|||||||
try {
|
try {
|
||||||
let sonarr: SonarrSeries | undefined;
|
let sonarr: SonarrSeries | undefined;
|
||||||
|
|
||||||
if (!server.is4k && media.externalServiceId && !is4k) {
|
if (media.externalServiceId && !is4k) {
|
||||||
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId);
|
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId);
|
||||||
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] =
|
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] =
|
||||||
sonarr.seasons;
|
sonarr.seasons;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (server.is4k && media.externalServiceId4k && is4k) {
|
if (media.externalServiceId4k && is4k) {
|
||||||
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId4k);
|
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId4k);
|
||||||
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] =
|
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] =
|
||||||
sonarr.seasons;
|
sonarr.seasons;
|
||||||
@@ -815,7 +762,7 @@ class AvailabilitySync {
|
|||||||
}] from Sonarr.`,
|
}] from Sonarr.`,
|
||||||
{
|
{
|
||||||
errorMessage: ex.message,
|
errorMessage: ex.message,
|
||||||
label: 'AvailabilitySync',
|
label: 'Availability Sync',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -861,7 +808,9 @@ class AvailabilitySync {
|
|||||||
// Check each sonarr instance to see if the media still exists
|
// Check each sonarr instance to see if the media still exists
|
||||||
// If found, we will assume the media exists and prevent removal
|
// If found, we will assume the media exists and prevent removal
|
||||||
// We can use the cache we built when we fetched the series with mediaExistsInSonarr
|
// We can use the cache we built when we fetched the series with mediaExistsInSonarr
|
||||||
for (const server of this.sonarrServers) {
|
for (const server of this.sonarrServers.filter(
|
||||||
|
(server) => server.is4k === is4k
|
||||||
|
)) {
|
||||||
let sonarrSeasons: SonarrSeason[] | undefined;
|
let sonarrSeasons: SonarrSeason[] | undefined;
|
||||||
|
|
||||||
if (media.externalServiceId && !is4k) {
|
if (media.externalServiceId && !is4k) {
|
||||||
@@ -936,7 +885,7 @@ class AvailabilitySync {
|
|||||||
} [TMDB ID ${media.tmdbId}] from Plex.`,
|
} [TMDB ID ${media.tmdbId}] from Plex.`,
|
||||||
{
|
{
|
||||||
errorMessage: ex.message,
|
errorMessage: ex.message,
|
||||||
label: 'AvailabilitySync',
|
label: 'Availability Sync',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1125,4 +1074,5 @@ class AvailabilitySync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const availabilitySync = new AvailabilitySync();
|
const availabilitySync = new AvailabilitySync();
|
||||||
|
|
||||||
export default availabilitySync;
|
export default availabilitySync;
|
||||||
|
|||||||
@@ -150,6 +150,8 @@ class ImageProxy {
|
|||||||
baseURL: baseUrl,
|
baseURL: baseUrl,
|
||||||
headers: options.headers,
|
headers: options.headers,
|
||||||
});
|
});
|
||||||
|
this.axios.interceptors.request = axios.interceptors.request;
|
||||||
|
this.axios.interceptors.response = axios.interceptors.response;
|
||||||
|
|
||||||
if (options.rateLimitOptions) {
|
if (options.rateLimitOptions) {
|
||||||
this.axios = rateLimit(this.axios, options.rateLimitOptions);
|
this.axios = rateLimit(this.axios, options.rateLimitOptions);
|
||||||
|
|||||||
164
server/lib/notifications/agents/ntfy.ts
Normal file
164
server/lib/notifications/agents/ntfy.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
|
||||||
|
import type { NotificationAgentNtfy } from '@server/lib/settings';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { hasNotificationType, Notification } from '..';
|
||||||
|
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||||
|
import { BaseAgent } from './agent';
|
||||||
|
|
||||||
|
class NtfyAgent
|
||||||
|
extends BaseAgent<NotificationAgentNtfy>
|
||||||
|
implements NotificationAgent
|
||||||
|
{
|
||||||
|
protected getSettings(): NotificationAgentNtfy {
|
||||||
|
if (this.settings) {
|
||||||
|
return this.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
return settings.notifications.agents.ntfy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPayload(type: Notification, payload: NotificationPayload) {
|
||||||
|
const { applicationUrl } = getSettings().main;
|
||||||
|
|
||||||
|
const topic = this.getSettings().options.topic;
|
||||||
|
const priority = 3;
|
||||||
|
|
||||||
|
const title = payload.event
|
||||||
|
? `${payload.event} - ${payload.subject}`
|
||||||
|
: payload.subject;
|
||||||
|
let message = payload.message ?? '';
|
||||||
|
|
||||||
|
if (payload.request) {
|
||||||
|
message += `\n\nRequested By: ${payload.request.requestedBy.displayName}`;
|
||||||
|
|
||||||
|
let status = '';
|
||||||
|
switch (type) {
|
||||||
|
case Notification.MEDIA_PENDING:
|
||||||
|
status = 'Pending Approval';
|
||||||
|
break;
|
||||||
|
case Notification.MEDIA_APPROVED:
|
||||||
|
case Notification.MEDIA_AUTO_APPROVED:
|
||||||
|
status = 'Processing';
|
||||||
|
break;
|
||||||
|
case Notification.MEDIA_AVAILABLE:
|
||||||
|
status = 'Available';
|
||||||
|
break;
|
||||||
|
case Notification.MEDIA_DECLINED:
|
||||||
|
status = 'Declined';
|
||||||
|
break;
|
||||||
|
case Notification.MEDIA_FAILED:
|
||||||
|
status = 'Failed';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
message += `\nRequest Status: ${status}`;
|
||||||
|
}
|
||||||
|
} else if (payload.comment) {
|
||||||
|
message += `\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message}`;
|
||||||
|
} else if (payload.issue) {
|
||||||
|
message += `\n\nReported By: ${payload.issue.createdBy.displayName}`;
|
||||||
|
message += `\nIssue Type: ${IssueTypeName[payload.issue.issueType]}`;
|
||||||
|
message += `\nIssue Status: ${
|
||||||
|
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const extra of payload.extra ?? []) {
|
||||||
|
message += `\n\n**${extra.name}**\n${extra.value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attach = payload.image;
|
||||||
|
|
||||||
|
let click;
|
||||||
|
if (applicationUrl && payload.media) {
|
||||||
|
click = `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
topic,
|
||||||
|
priority,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
attach,
|
||||||
|
click,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public shouldSend(): boolean {
|
||||||
|
const settings = this.getSettings();
|
||||||
|
|
||||||
|
if (settings.enabled && settings.options.url && settings.options.topic) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async send(
|
||||||
|
type: Notification,
|
||||||
|
payload: NotificationPayload
|
||||||
|
): Promise<boolean> {
|
||||||
|
const settings = this.getSettings();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!payload.notifySystem ||
|
||||||
|
!hasNotificationType(type, settings.types ?? 0)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Sending ntfy notification', {
|
||||||
|
label: 'Notifications',
|
||||||
|
type: Notification[type],
|
||||||
|
subject: payload.subject,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
let authHeader;
|
||||||
|
if (
|
||||||
|
settings.options.authMethodUsernamePassword &&
|
||||||
|
settings.options.username &&
|
||||||
|
settings.options.password
|
||||||
|
) {
|
||||||
|
const encodedAuth = Buffer.from(
|
||||||
|
`${settings.options.username}:${settings.options.password}`
|
||||||
|
).toString('base64');
|
||||||
|
|
||||||
|
authHeader = `Basic ${encodedAuth}`;
|
||||||
|
} else if (settings.options.authMethodToken) {
|
||||||
|
authHeader = `Bearer ${settings.options.token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await axios.post(
|
||||||
|
settings.options.url,
|
||||||
|
this.buildPayload(type, payload),
|
||||||
|
authHeader
|
||||||
|
? {
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Error sending ntfy notification', {
|
||||||
|
label: 'Notifications',
|
||||||
|
type: Notification[type],
|
||||||
|
subject: payload.subject,
|
||||||
|
errorMessage: e.message,
|
||||||
|
response: e?.response?.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NtfyAgent;
|
||||||
@@ -68,7 +68,7 @@ class PushoverAgent
|
|||||||
logger.error('Error getting image payload', {
|
logger.error('Error getting image payload', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: e?.response?.data,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ class WebPushAgent
|
|||||||
const allSubs = await userPushSubRepository
|
const allSubs = await userPushSubRepository
|
||||||
.createQueryBuilder('pushSub')
|
.createQueryBuilder('pushSub')
|
||||||
.leftJoinAndSelect('pushSub.user', 'user')
|
.leftJoinAndSelect('pushSub.user', 'user')
|
||||||
.where('pushSub.userId IN (:users)', {
|
.where('pushSub.userId IN (:...users)', {
|
||||||
users: manageUsers.map((user) => user.id),
|
users: manageUsers.map((user) => user.id),
|
||||||
})
|
})
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|||||||
@@ -281,7 +281,9 @@ class BaseScanner<T> {
|
|||||||
? MediaStatus.AVAILABLE
|
? MediaStatus.AVAILABLE
|
||||||
: season.episodes > 0
|
: season.episodes > 0
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
? MediaStatus.PARTIALLY_AVAILABLE
|
||||||
: !season.is4kOverride && season.processing
|
: !season.is4kOverride &&
|
||||||
|
season.processing &&
|
||||||
|
existingSeason.status !== MediaStatus.DELETED
|
||||||
? MediaStatus.PROCESSING
|
? MediaStatus.PROCESSING
|
||||||
: existingSeason.status;
|
: existingSeason.status;
|
||||||
|
|
||||||
@@ -294,7 +296,9 @@ class BaseScanner<T> {
|
|||||||
? MediaStatus.AVAILABLE
|
? MediaStatus.AVAILABLE
|
||||||
: this.enable4kShow && season.episodes4k > 0
|
: this.enable4kShow && season.episodes4k > 0
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
? MediaStatus.PARTIALLY_AVAILABLE
|
||||||
: season.is4kOverride && season.processing
|
: season.is4kOverride &&
|
||||||
|
season.processing &&
|
||||||
|
existingSeason.status4k !== MediaStatus.DELETED
|
||||||
? MediaStatus.PROCESSING
|
? MediaStatus.PROCESSING
|
||||||
: existingSeason.status4k;
|
: existingSeason.status4k;
|
||||||
} else {
|
} else {
|
||||||
@@ -324,19 +328,25 @@ class BaseScanner<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We want to skip specials when checking if a show is available
|
||||||
const isAllStandardSeasons =
|
const isAllStandardSeasons =
|
||||||
seasons.length &&
|
seasons.length &&
|
||||||
seasons.every(
|
seasons
|
||||||
(season) =>
|
.filter((season) => season.seasonNumber !== 0)
|
||||||
season.episodes === season.totalEpisodes && season.episodes > 0
|
.every(
|
||||||
);
|
(season) =>
|
||||||
|
season.episodes === season.totalEpisodes && season.episodes > 0
|
||||||
|
);
|
||||||
|
|
||||||
const isAll4kSeasons =
|
const isAll4kSeasons =
|
||||||
seasons.length &&
|
seasons.length &&
|
||||||
seasons.every(
|
seasons
|
||||||
(season) =>
|
.filter((season) => season.seasonNumber !== 0)
|
||||||
season.episodes4k === season.totalEpisodes && season.episodes4k > 0
|
.every(
|
||||||
);
|
(season) =>
|
||||||
|
season.episodes4k === season.totalEpisodes &&
|
||||||
|
season.episodes4k > 0
|
||||||
|
);
|
||||||
|
|
||||||
if (media) {
|
if (media) {
|
||||||
media.seasons = [...media.seasons, ...newSeasons];
|
media.seasons = [...media.seasons, ...newSeasons];
|
||||||
@@ -398,16 +408,23 @@ class BaseScanner<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If the show is already available, and there are no new seasons, dont adjust
|
// If the show is already available, and there are no new seasons, dont adjust
|
||||||
// the status
|
// the status. Skip specials when performing availability check
|
||||||
const shouldStayAvailable =
|
const shouldStayAvailable =
|
||||||
media.status === MediaStatus.AVAILABLE &&
|
media.status === MediaStatus.AVAILABLE &&
|
||||||
newSeasons.filter((season) => season.status !== MediaStatus.UNKNOWN)
|
newSeasons.filter(
|
||||||
.length === 0;
|
(season) =>
|
||||||
|
season.status !== MediaStatus.UNKNOWN &&
|
||||||
|
season.status !== MediaStatus.DELETED &&
|
||||||
|
season.seasonNumber !== 0
|
||||||
|
).length === 0;
|
||||||
const shouldStayAvailable4k =
|
const shouldStayAvailable4k =
|
||||||
media.status4k === MediaStatus.AVAILABLE &&
|
media.status4k === MediaStatus.AVAILABLE &&
|
||||||
newSeasons.filter((season) => season.status4k !== MediaStatus.UNKNOWN)
|
newSeasons.filter(
|
||||||
.length === 0;
|
(season) =>
|
||||||
|
season.status4k !== MediaStatus.UNKNOWN &&
|
||||||
|
season.status4k !== MediaStatus.DELETED &&
|
||||||
|
season.seasonNumber !== 0
|
||||||
|
).length === 0;
|
||||||
media.status =
|
media.status =
|
||||||
isAllStandardSeasons || shouldStayAvailable
|
isAllStandardSeasons || shouldStayAvailable
|
||||||
? MediaStatus.AVAILABLE
|
? MediaStatus.AVAILABLE
|
||||||
@@ -417,11 +434,13 @@ class BaseScanner<T> {
|
|||||||
season.status === MediaStatus.AVAILABLE
|
season.status === MediaStatus.AVAILABLE
|
||||||
)
|
)
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
? MediaStatus.PARTIALLY_AVAILABLE
|
||||||
: !seasons.length ||
|
: (!seasons.length && media.status !== MediaStatus.DELETED) ||
|
||||||
media.seasons.some(
|
media.seasons.some(
|
||||||
(season) => season.status === MediaStatus.PROCESSING
|
(season) => season.status === MediaStatus.PROCESSING
|
||||||
)
|
)
|
||||||
? MediaStatus.PROCESSING
|
? MediaStatus.PROCESSING
|
||||||
|
: media.status === MediaStatus.DELETED
|
||||||
|
? MediaStatus.DELETED
|
||||||
: MediaStatus.UNKNOWN;
|
: MediaStatus.UNKNOWN;
|
||||||
media.status4k =
|
media.status4k =
|
||||||
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
|
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
|
||||||
@@ -433,11 +452,13 @@ class BaseScanner<T> {
|
|||||||
season.status4k === MediaStatus.AVAILABLE
|
season.status4k === MediaStatus.AVAILABLE
|
||||||
)
|
)
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
? MediaStatus.PARTIALLY_AVAILABLE
|
||||||
: !seasons.length ||
|
: (!seasons.length && media.status4k !== MediaStatus.DELETED) ||
|
||||||
media.seasons.some(
|
media.seasons.some(
|
||||||
(season) => season.status4k === MediaStatus.PROCESSING
|
(season) => season.status4k === MediaStatus.PROCESSING
|
||||||
)
|
)
|
||||||
? MediaStatus.PROCESSING
|
? MediaStatus.PROCESSING
|
||||||
|
: media.status4k === MediaStatus.DELETED
|
||||||
|
? MediaStatus.DELETED
|
||||||
: MediaStatus.UNKNOWN;
|
: MediaStatus.UNKNOWN;
|
||||||
await mediaRepository.save(media);
|
await mediaRepository.save(media);
|
||||||
this.log(`Updating existing title: ${title}`);
|
this.log(`Updating existing title: ${title}`);
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export interface MainSettings {
|
|||||||
tv: Quota;
|
tv: Quota;
|
||||||
};
|
};
|
||||||
hideAvailable: boolean;
|
hideAvailable: boolean;
|
||||||
|
hideBlacklisted: boolean;
|
||||||
localLogin: boolean;
|
localLogin: boolean;
|
||||||
mediaServerLogin: boolean;
|
mediaServerLogin: boolean;
|
||||||
newPlexLogin: boolean;
|
newPlexLogin: boolean;
|
||||||
@@ -134,10 +135,12 @@ export interface MainSettings {
|
|||||||
partialRequestsEnabled: boolean;
|
partialRequestsEnabled: boolean;
|
||||||
enableSpecialEpisodes: boolean;
|
enableSpecialEpisodes: boolean;
|
||||||
locale: string;
|
locale: string;
|
||||||
|
youtubeUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NetworkSettings {
|
export interface NetworkSettings {
|
||||||
csrfProtection: boolean;
|
csrfProtection: boolean;
|
||||||
|
forceIpv4First: boolean;
|
||||||
trustProxy: boolean;
|
trustProxy: boolean;
|
||||||
proxy: ProxySettings;
|
proxy: ProxySettings;
|
||||||
}
|
}
|
||||||
@@ -150,6 +153,7 @@ interface FullPublicSettings extends PublicSettings {
|
|||||||
applicationTitle: string;
|
applicationTitle: string;
|
||||||
applicationUrl: string;
|
applicationUrl: string;
|
||||||
hideAvailable: boolean;
|
hideAvailable: boolean;
|
||||||
|
hideBlacklisted: boolean;
|
||||||
localLogin: boolean;
|
localLogin: boolean;
|
||||||
mediaServerLogin: boolean;
|
mediaServerLogin: boolean;
|
||||||
movie4kEnabled: boolean;
|
movie4kEnabled: boolean;
|
||||||
@@ -170,6 +174,7 @@ interface FullPublicSettings extends PublicSettings {
|
|||||||
emailEnabled: boolean;
|
emailEnabled: boolean;
|
||||||
userEmailRequired: boolean;
|
userEmailRequired: boolean;
|
||||||
newPlexLogin: boolean;
|
newPlexLogin: boolean;
|
||||||
|
youtubeUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationAgentConfig {
|
export interface NotificationAgentConfig {
|
||||||
@@ -259,10 +264,23 @@ export interface NotificationAgentGotify extends NotificationAgentConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NotificationAgentNtfy extends NotificationAgentConfig {
|
||||||
|
options: {
|
||||||
|
url: string;
|
||||||
|
topic: string;
|
||||||
|
authMethodUsernamePassword?: boolean;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
authMethodToken?: boolean;
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export enum NotificationAgentKey {
|
export enum NotificationAgentKey {
|
||||||
DISCORD = 'discord',
|
DISCORD = 'discord',
|
||||||
EMAIL = 'email',
|
EMAIL = 'email',
|
||||||
GOTIFY = 'gotify',
|
GOTIFY = 'gotify',
|
||||||
|
NTFY = 'ntfy',
|
||||||
PUSHBULLET = 'pushbullet',
|
PUSHBULLET = 'pushbullet',
|
||||||
PUSHOVER = 'pushover',
|
PUSHOVER = 'pushover',
|
||||||
SLACK = 'slack',
|
SLACK = 'slack',
|
||||||
@@ -275,6 +293,7 @@ interface NotificationAgents {
|
|||||||
discord: NotificationAgentDiscord;
|
discord: NotificationAgentDiscord;
|
||||||
email: NotificationAgentEmail;
|
email: NotificationAgentEmail;
|
||||||
gotify: NotificationAgentGotify;
|
gotify: NotificationAgentGotify;
|
||||||
|
ntfy: NotificationAgentNtfy;
|
||||||
lunasea: NotificationAgentLunaSea;
|
lunasea: NotificationAgentLunaSea;
|
||||||
pushbullet: NotificationAgentPushbullet;
|
pushbullet: NotificationAgentPushbullet;
|
||||||
pushover: NotificationAgentPushover;
|
pushover: NotificationAgentPushover;
|
||||||
@@ -346,6 +365,7 @@ class Settings {
|
|||||||
tv: {},
|
tv: {},
|
||||||
},
|
},
|
||||||
hideAvailable: false,
|
hideAvailable: false,
|
||||||
|
hideBlacklisted: false,
|
||||||
localLogin: true,
|
localLogin: true,
|
||||||
mediaServerLogin: true,
|
mediaServerLogin: true,
|
||||||
newPlexLogin: true,
|
newPlexLogin: true,
|
||||||
@@ -358,6 +378,7 @@ class Settings {
|
|||||||
partialRequestsEnabled: true,
|
partialRequestsEnabled: true,
|
||||||
enableSpecialEpisodes: false,
|
enableSpecialEpisodes: false,
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
|
youtubeUrl: '',
|
||||||
},
|
},
|
||||||
plex: {
|
plex: {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -471,6 +492,14 @@ class Settings {
|
|||||||
priority: 0,
|
priority: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
ntfy: {
|
||||||
|
enabled: false,
|
||||||
|
types: 0,
|
||||||
|
options: {
|
||||||
|
url: '',
|
||||||
|
topic: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
jobs: {
|
jobs: {
|
||||||
@@ -516,6 +545,7 @@ class Settings {
|
|||||||
},
|
},
|
||||||
network: {
|
network: {
|
||||||
csrfProtection: false,
|
csrfProtection: false,
|
||||||
|
forceIpv4First: false,
|
||||||
trustProxy: false,
|
trustProxy: false,
|
||||||
proxy: {
|
proxy: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -596,6 +626,7 @@ class Settings {
|
|||||||
applicationTitle: this.data.main.applicationTitle,
|
applicationTitle: this.data.main.applicationTitle,
|
||||||
applicationUrl: this.data.main.applicationUrl,
|
applicationUrl: this.data.main.applicationUrl,
|
||||||
hideAvailable: this.data.main.hideAvailable,
|
hideAvailable: this.data.main.hideAvailable,
|
||||||
|
hideBlacklisted: this.data.main.hideBlacklisted,
|
||||||
localLogin: this.data.main.localLogin,
|
localLogin: this.data.main.localLogin,
|
||||||
mediaServerLogin: this.data.main.mediaServerLogin,
|
mediaServerLogin: this.data.main.mediaServerLogin,
|
||||||
jellyfinExternalHost: this.data.jellyfin.externalHostname,
|
jellyfinExternalHost: this.data.jellyfin.externalHostname,
|
||||||
@@ -620,6 +651,7 @@ class Settings {
|
|||||||
userEmailRequired:
|
userEmailRequired:
|
||||||
this.data.notifications.agents.email.options.userEmailRequired,
|
this.data.notifications.agents.email.options.userEmailRequired,
|
||||||
newPlexLogin: this.data.main.newPlexLogin,
|
newPlexLogin: this.data.main.newPlexLogin,
|
||||||
|
youtubeUrl: this.data.main.youtubeUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
17
server/migration/postgres/1745492376568-UpdateWebPush.ts
Normal file
17
server/migration/postgres/1745492376568-UpdateWebPush.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class UpdateWebPush1745492376568 implements MigrationInterface {
|
||||||
|
name = 'UpdateWebPush1745492376568';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "blacklist" RENAME COLUMN "blacklistedtags" TO "blacklistedTags"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "blacklist" RENAME COLUMN "blacklistedTags" TO "blacklistedtags"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
231
server/migration/postgres/1746811308203-FixIssueTimestamps.ts
Normal file
231
server/migration/postgres/1746811308203-FixIssueTimestamps.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class FixIssueTimestamps1746811308203 implements MigrationInterface {
|
||||||
|
name = 'FixIssueTimestamps1746811308203';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "watchlist"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "watchlist"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "override_rule"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "override_rule"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "season_request"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "season_request"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "media_request"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "media_request"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "user_push_subscription"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "user"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "user"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "blacklist"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "season"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "season"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "media"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "media"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "issue"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "issue"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "issue_comment"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "issue_comment"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "discover_slider"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "discover_slider"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "discover_slider"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "discover_slider"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "issue_comment"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "issue_comment"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "issue"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "issue"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "media"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "media"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "season"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "season"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "blacklist"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "user"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "user"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "user_push_subscription"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "media_request"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "media_request"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "season_request"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "season_request"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "override_rule"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "override_rule"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "watchlist"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "watchlist"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
server/migration/sqlite/1745492372230-UpdateWebPush.ts
Normal file
79
server/migration/sqlite/1745492372230-UpdateWebPush.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class UpdateWebPush1745492372230 implements MigrationInterface {
|
||||||
|
name = 'UpdateWebPush1745492372230';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, "blacklistedTags" varchar, CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "blacklist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_blacklist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,9 @@ import logger from '@server/logger';
|
|||||||
import { isAuthenticated } from '@server/middleware/auth';
|
import { isAuthenticated } from '@server/middleware/auth';
|
||||||
import { checkAvatarChanged } from '@server/routes/avatarproxy';
|
import { checkAvatarChanged } from '@server/routes/avatarproxy';
|
||||||
import { ApiError } from '@server/types/error';
|
import { ApiError } from '@server/types/error';
|
||||||
|
import { getAppVersion } from '@server/utils/appVersion';
|
||||||
import { getHostname } from '@server/utils/getHostname';
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
|
import axios from 'axios';
|
||||||
import * as EmailValidator from 'email-validator';
|
import * as EmailValidator from 'email-validator';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import net from 'net';
|
import net from 'net';
|
||||||
@@ -275,11 +277,14 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
select: { id: true, jellyfinDeviceId: true },
|
select: { id: true, jellyfinDeviceId: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
let deviceId = '';
|
let deviceId = 'BOT_jellyseerr';
|
||||||
if (user) {
|
if (user && user.id === 1) {
|
||||||
deviceId = user.jellyfinDeviceId ?? '';
|
// Admin is always BOT_jellyseerr
|
||||||
} else {
|
deviceId = 'BOT_jellyseerr';
|
||||||
deviceId = Buffer.from(`BOT_jellyseerr_${body.username ?? ''}`).toString(
|
} else if (user && user.jellyfinDeviceId) {
|
||||||
|
deviceId = user.jellyfinDeviceId;
|
||||||
|
} else if (body.username) {
|
||||||
|
deviceId = Buffer.from(`BOT_jellyseerr_${body.username}`).toString(
|
||||||
'base64'
|
'base64'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -511,7 +516,9 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
case ApiErrorCode.InvalidUrl:
|
case ApiErrorCode.InvalidUrl:
|
||||||
logger.error(
|
logger.error(
|
||||||
`The provided ${
|
`The provided ${
|
||||||
process.env.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin'
|
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||||
|
? ServerType.JELLYFIN
|
||||||
|
: ServerType.EMBY
|
||||||
} is invalid or the server is not reachable.`,
|
} is invalid or the server is not reachable.`,
|
||||||
{
|
{
|
||||||
label: 'Auth',
|
label: 'Auth',
|
||||||
@@ -714,17 +721,79 @@ authRoutes.post('/local', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
authRoutes.post('/logout', (req, res, next) => {
|
authRoutes.post('/logout', async (req, res, next) => {
|
||||||
req.session?.destroy((err) => {
|
try {
|
||||||
if (err) {
|
const userId = req.session?.userId;
|
||||||
return next({
|
if (!userId) {
|
||||||
status: 500,
|
return res.status(200).json({ status: 'ok' });
|
||||||
message: 'Something went wrong.',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).json({ status: 'ok' });
|
const settings = getSettings();
|
||||||
});
|
const isJellyfinOrEmby =
|
||||||
|
settings.main.mediaServerType === MediaServerType.JELLYFIN ||
|
||||||
|
settings.main.mediaServerType === MediaServerType.EMBY;
|
||||||
|
|
||||||
|
if (isJellyfinOrEmby) {
|
||||||
|
const user = await getRepository(User)
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.addSelect(['user.jellyfinUserId', 'user.jellyfinDeviceId'])
|
||||||
|
.where('user.id = :id', { id: userId })
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (user?.jellyfinUserId && user.jellyfinDeviceId) {
|
||||||
|
try {
|
||||||
|
const baseUrl = getHostname();
|
||||||
|
try {
|
||||||
|
await axios.delete(`${baseUrl}/Devices`, {
|
||||||
|
params: { Id: user.jellyfinDeviceId },
|
||||||
|
headers: {
|
||||||
|
'X-Emby-Authorization': `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="jellyseerr", Version="${getAppVersion()}", Token="${
|
||||||
|
settings.jellyfin.apiKey
|
||||||
|
}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete Jellyfin device', {
|
||||||
|
label: 'Auth',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
userId: user.id,
|
||||||
|
jellyfinUserId: user.jellyfinUserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete Jellyfin device', {
|
||||||
|
label: 'Auth',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
userId: user.id,
|
||||||
|
jellyfinUserId: user.jellyfinUserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session?.destroy((err: Error | null) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error('Failed to destroy session', {
|
||||||
|
label: 'Auth',
|
||||||
|
error: err.message,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
return next({ status: 500, message: 'Failed to destroy session.' });
|
||||||
|
}
|
||||||
|
logger.info('Successfully logged out user', {
|
||||||
|
label: 'Auth',
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
res.status(200).json({ status: 'ok' });
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error during logout process', {
|
||||||
|
label: 'Auth',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
userId: req.session?.userId,
|
||||||
|
});
|
||||||
|
next({ status: 500, message: 'Error during logout process.' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
authRoutes.post('/reset-password', async (req, res, next) => {
|
authRoutes.post('/reset-password', async (req, res, next) => {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ async function initAvatarImageProxy() {
|
|||||||
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
|
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
|
||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
const deviceId = admin?.jellyfinDeviceId;
|
const deviceId = admin?.jellyfinDeviceId || 'BOT_jellyseerr';
|
||||||
const authToken = getSettings().jellyfin.apiKey;
|
const authToken = getSettings().jellyfin.apiKey;
|
||||||
_avatarImageProxy = new ImageProxy('avatar', '', {
|
_avatarImageProxy = new ImageProxy('avatar', '', {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -72,16 +72,25 @@ const QueryFilterOptions = z.object({
|
|||||||
watchProviders: z.coerce.string().optional(),
|
watchProviders: z.coerce.string().optional(),
|
||||||
watchRegion: z.coerce.string().optional(),
|
watchRegion: z.coerce.string().optional(),
|
||||||
status: z.coerce.string().optional(),
|
status: z.coerce.string().optional(),
|
||||||
|
certification: z.coerce.string().optional(),
|
||||||
|
certificationGte: z.coerce.string().optional(),
|
||||||
|
certificationLte: z.coerce.string().optional(),
|
||||||
|
certificationCountry: z.coerce.string().optional(),
|
||||||
|
certificationMode: z.enum(['exact', 'range']).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
||||||
|
const ApiQuerySchema = QueryFilterOptions.omit({
|
||||||
|
certificationMode: true,
|
||||||
|
});
|
||||||
|
|
||||||
discoverRoutes.get('/movies', async (req, res, next) => {
|
discoverRoutes.get('/movies', async (req, res, next) => {
|
||||||
const tmdb = createTmdbWithRegionLanguage(req.user);
|
const tmdb = createTmdbWithRegionLanguage(req.user);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const query = QueryFilterOptions.parse(req.query);
|
const query = ApiQuerySchema.parse(req.query);
|
||||||
const keywords = query.keywords;
|
const keywords = query.keywords;
|
||||||
|
|
||||||
const data = await tmdb.getDiscoverMovies({
|
const data = await tmdb.getDiscoverMovies({
|
||||||
page: Number(query.page),
|
page: Number(query.page),
|
||||||
sortBy: query.sortBy as SortOptions,
|
sortBy: query.sortBy as SortOptions,
|
||||||
@@ -104,6 +113,10 @@ discoverRoutes.get('/movies', async (req, res, next) => {
|
|||||||
voteCountLte: query.voteCountLte,
|
voteCountLte: query.voteCountLte,
|
||||||
watchProviders: query.watchProviders,
|
watchProviders: query.watchProviders,
|
||||||
watchRegion: query.watchRegion,
|
watchRegion: query.watchRegion,
|
||||||
|
certification: query.certification,
|
||||||
|
certificationGte: query.certificationGte,
|
||||||
|
certificationLte: query.certificationLte,
|
||||||
|
certificationCountry: query.certificationCountry,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
@@ -362,7 +375,7 @@ discoverRoutes.get('/tv', async (req, res, next) => {
|
|||||||
const tmdb = createTmdbWithRegionLanguage(req.user);
|
const tmdb = createTmdbWithRegionLanguage(req.user);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const query = QueryFilterOptions.parse(req.query);
|
const query = ApiQuerySchema.parse(req.query);
|
||||||
const keywords = query.keywords;
|
const keywords = query.keywords;
|
||||||
const data = await tmdb.getDiscoverTv({
|
const data = await tmdb.getDiscoverTv({
|
||||||
page: Number(query.page),
|
page: Number(query.page),
|
||||||
@@ -387,6 +400,10 @@ discoverRoutes.get('/tv', async (req, res, next) => {
|
|||||||
watchProviders: query.watchProviders,
|
watchProviders: query.watchProviders,
|
||||||
watchRegion: query.watchRegion,
|
watchRegion: query.watchRegion,
|
||||||
withStatus: query.status,
|
withStatus: query.status,
|
||||||
|
certification: query.certification,
|
||||||
|
certificationGte: query.certificationGte,
|
||||||
|
certificationLte: query.certificationLte,
|
||||||
|
certificationCountry: query.certificationCountry,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
|
|||||||
@@ -3,20 +3,36 @@ import logger from '@server/logger';
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
|
const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
|
||||||
rateLimitOptions: {
|
rateLimitOptions: {
|
||||||
maxRequests: 20,
|
maxRequests: 20,
|
||||||
maxRPS: 50,
|
maxRPS: 50,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const tvdbImageProxy = new ImageProxy('tvdb', 'https://artworks.thetvdb.com', {
|
||||||
|
rateLimitOptions: {
|
||||||
|
maxRequests: 20,
|
||||||
|
maxRPS: 50,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
router.get('/:type/*', async (req, res) => {
|
||||||
* Image Proxy
|
const imagePath = req.path.replace(/^\/\w+/, '');
|
||||||
*/
|
|
||||||
router.get('/*', async (req, res) => {
|
|
||||||
const imagePath = req.path.replace('/image', '');
|
|
||||||
try {
|
try {
|
||||||
const imageData = await tmdbImageProxy.getImage(imagePath);
|
let imageData;
|
||||||
|
if (req.params.type === 'tmdb') {
|
||||||
|
imageData = await tmdbImageProxy.getImage(imagePath);
|
||||||
|
} else if (req.params.type === 'tvdb') {
|
||||||
|
imageData = await tvdbImageProxy.getImage(imagePath);
|
||||||
|
} else {
|
||||||
|
logger.error('Unsupported image type', {
|
||||||
|
imagePath,
|
||||||
|
type: req.params.type,
|
||||||
|
});
|
||||||
|
res.status(400).send('Unsupported image type');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': `image/${imageData.meta.extension}`,
|
'Content-Type': `image/${imageData.meta.extension}`,
|
||||||
|
|||||||
@@ -401,6 +401,48 @@ router.get('/watchproviders/tv', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/certifications/movie',
|
||||||
|
isAuthenticated(),
|
||||||
|
async (req, res, next) => {
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const certifications = await tmdb.getMovieCertifications();
|
||||||
|
|
||||||
|
return res.status(200).json(certifications);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Something went wrong retrieving movie certifications', {
|
||||||
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Unable to retrieve movie certifications.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get('/certifications/tv', isAuthenticated(), async (req, res, next) => {
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const certifications = await tmdb.getTvCertifications();
|
||||||
|
|
||||||
|
return res.status(200).json(certifications);
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('Something went wrong retrieving TV certifications', {
|
||||||
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Unable to retrieve TV certifications.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/', (_req, res) => {
|
router.get('/', (_req, res) => {
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
api: 'Jellyseerr API',
|
api: 'Jellyseerr API',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import TheMovieDb from '@server/api/themoviedb';
|
|||||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
|
import Season from '@server/entity/Season';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import type {
|
import type {
|
||||||
MediaResultsResponse,
|
MediaResultsResponse,
|
||||||
@@ -101,6 +102,7 @@ mediaRoutes.post<
|
|||||||
isAuthenticated(Permission.MANAGE_REQUESTS),
|
isAuthenticated(Permission.MANAGE_REQUESTS),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
|
const seasonRepository = getRepository(Season);
|
||||||
|
|
||||||
const media = await mediaRepository.findOne({
|
const media = await mediaRepository.findOne({
|
||||||
where: { id: Number(req.params.id) },
|
where: { id: Number(req.params.id) },
|
||||||
@@ -115,11 +117,25 @@ mediaRoutes.post<
|
|||||||
switch (req.params.status) {
|
switch (req.params.status) {
|
||||||
case 'available':
|
case 'available':
|
||||||
media[is4k ? 'status4k' : 'status'] = MediaStatus.AVAILABLE;
|
media[is4k ? 'status4k' : 'status'] = MediaStatus.AVAILABLE;
|
||||||
|
|
||||||
if (media.mediaType === MediaType.TV) {
|
if (media.mediaType === MediaType.TV) {
|
||||||
// Mark all seasons available
|
const expectedSeasons = req.body.seasons ?? [];
|
||||||
media.seasons.forEach((season) => {
|
|
||||||
|
for (const expectedSeason of expectedSeasons) {
|
||||||
|
let season = media.seasons.find(
|
||||||
|
(s) => s.seasonNumber === expectedSeason?.seasonNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!season) {
|
||||||
|
// Create the season if it doesn't exist
|
||||||
|
season = seasonRepository.create({
|
||||||
|
seasonNumber: expectedSeason?.seasonNumber,
|
||||||
|
});
|
||||||
|
media.seasons.push(season);
|
||||||
|
}
|
||||||
|
|
||||||
season[is4k ? 'status4k' : 'status'] = MediaStatus.AVAILABLE;
|
season[is4k ? 'status4k' : 'status'] = MediaStatus.AVAILABLE;
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'partial':
|
case 'partial':
|
||||||
|
|||||||
@@ -38,13 +38,13 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
const requestedBy = req.query.requestedBy
|
const requestedBy = req.query.requestedBy
|
||||||
? Number(req.query.requestedBy)
|
? Number(req.query.requestedBy)
|
||||||
: null;
|
: null;
|
||||||
|
const mediaType = (req.query.mediaType as MediaType | 'all') || 'all';
|
||||||
|
|
||||||
let statusFilter: MediaRequestStatus[];
|
let statusFilter: MediaRequestStatus[];
|
||||||
|
|
||||||
switch (req.query.filter) {
|
switch (req.query.filter) {
|
||||||
case 'approved':
|
case 'approved':
|
||||||
case 'processing':
|
case 'processing':
|
||||||
case 'available':
|
|
||||||
statusFilter = [MediaRequestStatus.APPROVED];
|
statusFilter = [MediaRequestStatus.APPROVED];
|
||||||
break;
|
break;
|
||||||
case 'pending':
|
case 'pending':
|
||||||
@@ -59,12 +59,18 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
case 'failed':
|
case 'failed':
|
||||||
statusFilter = [MediaRequestStatus.FAILED];
|
statusFilter = [MediaRequestStatus.FAILED];
|
||||||
break;
|
break;
|
||||||
|
case 'completed':
|
||||||
|
case 'available':
|
||||||
|
case 'deleted':
|
||||||
|
statusFilter = [MediaRequestStatus.COMPLETED];
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
statusFilter = [
|
statusFilter = [
|
||||||
MediaRequestStatus.PENDING,
|
MediaRequestStatus.PENDING,
|
||||||
MediaRequestStatus.APPROVED,
|
MediaRequestStatus.APPROVED,
|
||||||
MediaRequestStatus.DECLINED,
|
MediaRequestStatus.DECLINED,
|
||||||
MediaRequestStatus.FAILED,
|
MediaRequestStatus.FAILED,
|
||||||
|
MediaRequestStatus.COMPLETED,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +89,9 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
MediaStatus.PARTIALLY_AVAILABLE,
|
MediaStatus.PARTIALLY_AVAILABLE,
|
||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
|
case 'deleted':
|
||||||
|
mediaStatusFilter = [MediaStatus.DELETED];
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
mediaStatusFilter = [
|
mediaStatusFilter = [
|
||||||
MediaStatus.UNKNOWN,
|
MediaStatus.UNKNOWN,
|
||||||
@@ -90,6 +99,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
MediaStatus.PROCESSING,
|
MediaStatus.PROCESSING,
|
||||||
MediaStatus.PARTIALLY_AVAILABLE,
|
MediaStatus.PARTIALLY_AVAILABLE,
|
||||||
MediaStatus.AVAILABLE,
|
MediaStatus.AVAILABLE,
|
||||||
|
MediaStatus.DELETED,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +160,21 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch (mediaType) {
|
||||||
|
case 'all':
|
||||||
|
break;
|
||||||
|
case 'movie':
|
||||||
|
query = query.andWhere('request.type = :type', {
|
||||||
|
type: MediaType.MOVIE,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'tv':
|
||||||
|
query = query.andWhere('request.type = :type', {
|
||||||
|
type: MediaType.TV,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const [requests, requestCount] = await query
|
const [requests, requestCount] = await query
|
||||||
.orderBy(sortFilter, sortDirection)
|
.orderBy(sortFilter, sortDirection)
|
||||||
.take(pageSize)
|
.take(pageSize)
|
||||||
@@ -298,7 +323,7 @@ requestRoutes.get('/count', async (_req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const query = requestRepository
|
const query = requestRepository
|
||||||
.createQueryBuilder('request')
|
.createQueryBuilder('request')
|
||||||
.leftJoinAndSelect('request.media', 'media');
|
.innerJoinAndSelect('request.media', 'media');
|
||||||
|
|
||||||
const totalCount = await query.getCount();
|
const totalCount = await query.getCount();
|
||||||
|
|
||||||
@@ -492,7 +517,8 @@ requestRoutes.put<{ requestId: string }>(
|
|||||||
(r) =>
|
(r) =>
|
||||||
r.is4k === request.is4k &&
|
r.is4k === request.is4k &&
|
||||||
r.id !== request.id &&
|
r.id !== request.id &&
|
||||||
r.status !== MediaRequestStatus.DECLINED
|
r.status !== MediaRequestStatus.DECLINED &&
|
||||||
|
r.status !== MediaRequestStatus.COMPLETED
|
||||||
)
|
)
|
||||||
.reduce((seasons, r) => {
|
.reduce((seasons, r) => {
|
||||||
const combinedSeasons = r.seasons.map(
|
const combinedSeasons = r.seasons.map(
|
||||||
@@ -569,31 +595,6 @@ requestRoutes.delete('/:requestId', async (req, res, next) => {
|
|||||||
|
|
||||||
await requestRepository.remove(request);
|
await requestRepository.remove(request);
|
||||||
|
|
||||||
const mediaRepository = getRepository(Media);
|
|
||||||
|
|
||||||
const media = await mediaRepository.findOne({
|
|
||||||
where: { id: request.media.id },
|
|
||||||
relations: { requests: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (media) {
|
|
||||||
if (
|
|
||||||
!media.requests.some((r) => !r.is4k) &&
|
|
||||||
media.status !== MediaStatus.AVAILABLE
|
|
||||||
) {
|
|
||||||
media.status = MediaStatus.UNKNOWN;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!media.requests.some((r) => r.is4k) &&
|
|
||||||
media.status4k !== MediaStatus.AVAILABLE
|
|
||||||
) {
|
|
||||||
media.status4k = MediaStatus.UNKNOWN;
|
|
||||||
}
|
|
||||||
|
|
||||||
await mediaRepository.save(media);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Something went wrong deleting a request.', {
|
logger.error('Something went wrong deleting a request.', {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import DiscordAgent from '@server/lib/notifications/agents/discord';
|
|||||||
import EmailAgent from '@server/lib/notifications/agents/email';
|
import EmailAgent from '@server/lib/notifications/agents/email';
|
||||||
import GotifyAgent from '@server/lib/notifications/agents/gotify';
|
import GotifyAgent from '@server/lib/notifications/agents/gotify';
|
||||||
import LunaSeaAgent from '@server/lib/notifications/agents/lunasea';
|
import LunaSeaAgent from '@server/lib/notifications/agents/lunasea';
|
||||||
|
import NtfyAgent from '@server/lib/notifications/agents/ntfy';
|
||||||
import PushbulletAgent from '@server/lib/notifications/agents/pushbullet';
|
import PushbulletAgent from '@server/lib/notifications/agents/pushbullet';
|
||||||
import PushoverAgent from '@server/lib/notifications/agents/pushover';
|
import PushoverAgent from '@server/lib/notifications/agents/pushover';
|
||||||
import SlackAgent from '@server/lib/notifications/agents/slack';
|
import SlackAgent from '@server/lib/notifications/agents/slack';
|
||||||
@@ -413,4 +414,38 @@ notificationRoutes.post('/gotify/test', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
notificationRoutes.get('/ntfy', (_req, res) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
res.status(200).json(settings.notifications.agents.ntfy);
|
||||||
|
});
|
||||||
|
|
||||||
|
notificationRoutes.post('/ntfy', async (req, res) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
settings.notifications.agents.ntfy = req.body;
|
||||||
|
await settings.save();
|
||||||
|
|
||||||
|
res.status(200).json(settings.notifications.agents.ntfy);
|
||||||
|
});
|
||||||
|
|
||||||
|
notificationRoutes.post('/ntfy/test', async (req, res, next) => {
|
||||||
|
if (!req.user) {
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'User information is missing from the request.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ntfyAgent = new NtfyAgent(req.body);
|
||||||
|
if (await sendTestNotification(ntfyAgent, req.user)) {
|
||||||
|
return res.status(204).send();
|
||||||
|
} else {
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Failed to send ntfy notification.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default notificationRoutes;
|
export default notificationRoutes;
|
||||||
|
|||||||
@@ -240,8 +240,8 @@ router.get<{ userId: number }>(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get<{ userId: number; key: string }>(
|
router.get<{ userId: number; endpoint: string }>(
|
||||||
'/:userId/pushSubscription/:key',
|
'/:userId/pushSubscription/:endpoint',
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const userPushSubRepository = getRepository(UserPushSubscription);
|
const userPushSubRepository = getRepository(UserPushSubscription);
|
||||||
@@ -252,7 +252,7 @@ router.get<{ userId: number; key: string }>(
|
|||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
user: { id: req.params.userId },
|
user: { id: req.params.userId },
|
||||||
p256dh: req.params.key,
|
endpoint: req.params.endpoint,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -263,8 +263,8 @@ router.get<{ userId: number; key: string }>(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
router.delete<{ userId: number; key: string }>(
|
router.delete<{ userId: number; endpoint: string }>(
|
||||||
'/:userId/pushSubscription/:key',
|
'/:userId/pushSubscription/:endpoint',
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const userPushSubRepository = getRepository(UserPushSubscription);
|
const userPushSubRepository = getRepository(UserPushSubscription);
|
||||||
@@ -275,7 +275,7 @@ router.delete<{ userId: number; key: string }>(
|
|||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
user: { id: req.params.userId },
|
user: { id: req.params.userId },
|
||||||
p256dh: req.params.key,
|
endpoint: req.params.endpoint,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -284,7 +284,7 @@ router.delete<{ userId: number; key: string }>(
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Something went wrong deleting the user push subcription', {
|
logger.error('Something went wrong deleting the user push subcription', {
|
||||||
label: 'API',
|
label: 'API',
|
||||||
key: req.params.key,
|
endpoint: req.params.endpoint,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
});
|
});
|
||||||
return next({
|
return next({
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { ApiError } from '@server/types/error';
|
|||||||
import { getHostname } from '@server/utils/getHostname';
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import net from 'net';
|
import net from 'net';
|
||||||
|
import { Not } from 'typeorm';
|
||||||
import { canMakePermissionsChange } from '.';
|
import { canMakePermissionsChange } from '.';
|
||||||
|
|
||||||
const isOwnProfile = (): Middleware => {
|
const isOwnProfile = (): Middleware => {
|
||||||
@@ -125,8 +126,9 @@ userSettingsRoutes.post<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existingUser = await userRepository.findOne({
|
const existingUser = await userRepository.findOne({
|
||||||
where: { email: user.email },
|
where: { email: user.email, id: Not(user.id) },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (oldEmail !== user.email && existingUser) {
|
if (oldEmail !== user.email && existingUser) {
|
||||||
throw new ApiError(400, ApiErrorCode.InvalidEmail);
|
throw new ApiError(400, ApiErrorCode.InvalidEmail);
|
||||||
}
|
}
|
||||||
@@ -419,7 +421,9 @@ userSettingsRoutes.post<{ username: string; password: string }>(
|
|||||||
|
|
||||||
const hostname = getHostname();
|
const hostname = getHostname();
|
||||||
const deviceId = Buffer.from(
|
const deviceId = Buffer.from(
|
||||||
`BOT_jellyseerr_${req.user.username ?? ''}`
|
req.user?.id === 1
|
||||||
|
? 'BOT_jellyseerr'
|
||||||
|
: `BOT_jellyseerr_${req.user.username ?? ''}`
|
||||||
).toString('base64');
|
).toString('base64');
|
||||||
|
|
||||||
const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId);
|
const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId);
|
||||||
|
|||||||
818
server/subscriber/MediaRequestSubscriber.ts
Normal file
818
server/subscriber/MediaRequestSubscriber.ts
Normal file
@@ -0,0 +1,818 @@
|
|||||||
|
import type { RadarrMovieOptions } from '@server/api/servarr/radarr';
|
||||||
|
import RadarrAPI from '@server/api/servarr/radarr';
|
||||||
|
import type {
|
||||||
|
AddSeriesOptions,
|
||||||
|
SonarrSeries,
|
||||||
|
} from '@server/api/servarr/sonarr';
|
||||||
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
|
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||||
|
import {
|
||||||
|
MediaRequestStatus,
|
||||||
|
MediaStatus,
|
||||||
|
MediaType,
|
||||||
|
} from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import Media from '@server/entity/Media';
|
||||||
|
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
|
import SeasonRequest from '@server/entity/SeasonRequest';
|
||||||
|
import notificationManager, { Notification } from '@server/lib/notifications';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import { isEqual, truncate } from 'lodash';
|
||||||
|
import type {
|
||||||
|
EntityManager,
|
||||||
|
EntitySubscriberInterface,
|
||||||
|
InsertEvent,
|
||||||
|
RemoveEvent,
|
||||||
|
UpdateEvent,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { EventSubscriber } from 'typeorm';
|
||||||
|
|
||||||
|
@EventSubscriber()
|
||||||
|
export class MediaRequestSubscriber
|
||||||
|
implements EntitySubscriberInterface<MediaRequest>
|
||||||
|
{
|
||||||
|
private async notifyAvailableMovie(entity: MediaRequest) {
|
||||||
|
if (
|
||||||
|
entity.media[entity.is4k ? 'status4k' : 'status'] ===
|
||||||
|
MediaStatus.AVAILABLE
|
||||||
|
) {
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const movie = await tmdb.getMovie({
|
||||||
|
movieId: entity.media.tmdbId,
|
||||||
|
});
|
||||||
|
|
||||||
|
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||||
|
event: `${entity.is4k ? '4K ' : ''}Movie Request Now Available`,
|
||||||
|
notifyAdmin: false,
|
||||||
|
notifySystem: true,
|
||||||
|
notifyUser: entity.requestedBy,
|
||||||
|
subject: `${movie.title}${
|
||||||
|
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||||
|
}`,
|
||||||
|
message: truncate(movie.overview, {
|
||||||
|
length: 500,
|
||||||
|
separator: /\s/,
|
||||||
|
omission: '…',
|
||||||
|
}),
|
||||||
|
media: entity.media,
|
||||||
|
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||||
|
request: entity,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Something went wrong sending media notification(s)', {
|
||||||
|
label: 'Notifications',
|
||||||
|
errorMessage: e.message,
|
||||||
|
mediaId: entity.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async notifyAvailableSeries(entity: MediaRequest) {
|
||||||
|
// Find all seasons in the related media entity
|
||||||
|
// and see if they are available, then we can check
|
||||||
|
// if the request contains the same seasons
|
||||||
|
const requestedSeasons =
|
||||||
|
entity.seasons?.map((entitySeason) => entitySeason.seasonNumber) ?? [];
|
||||||
|
const availableSeasons = entity.media.seasons.filter(
|
||||||
|
(season) =>
|
||||||
|
season[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE &&
|
||||||
|
requestedSeasons.includes(season.seasonNumber)
|
||||||
|
);
|
||||||
|
const isMediaAvailable =
|
||||||
|
availableSeasons.length > 0 &&
|
||||||
|
availableSeasons.length === requestedSeasons.length;
|
||||||
|
|
||||||
|
if (isMediaAvailable) {
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tv = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
|
||||||
|
|
||||||
|
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||||
|
event: `${entity.is4k ? '4K ' : ''}Series Request Now Available`,
|
||||||
|
subject: `${tv.name}${
|
||||||
|
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
||||||
|
}`,
|
||||||
|
message: truncate(tv.overview, {
|
||||||
|
length: 500,
|
||||||
|
separator: /\s/,
|
||||||
|
omission: '…',
|
||||||
|
}),
|
||||||
|
notifyAdmin: false,
|
||||||
|
notifySystem: true,
|
||||||
|
notifyUser: entity.requestedBy,
|
||||||
|
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||||
|
media: entity.media,
|
||||||
|
extra: [
|
||||||
|
{
|
||||||
|
name: 'Requested Seasons',
|
||||||
|
value: entity.seasons
|
||||||
|
.map((season) => season.seasonNumber)
|
||||||
|
.join(', '),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
request: entity,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Something went wrong sending media notification(s)', {
|
||||||
|
label: 'Notifications',
|
||||||
|
errorMessage: e.message,
|
||||||
|
mediaId: entity.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendToRadarr(entity: MediaRequest): Promise<void> {
|
||||||
|
if (
|
||||||
|
entity.status === MediaRequestStatus.APPROVED &&
|
||||||
|
entity.type === MediaType.MOVIE
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const settings = getSettings();
|
||||||
|
if (settings.radarr.length === 0 && !settings.radarr[0]) {
|
||||||
|
logger.info(
|
||||||
|
'No Radarr server configured, skipping request processing',
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let radarrSettings = settings.radarr.find(
|
||||||
|
(radarr) => radarr.isDefault && radarr.is4k === entity.is4k
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
entity.serverId !== null &&
|
||||||
|
entity.serverId >= 0 &&
|
||||||
|
radarrSettings?.id !== entity.serverId
|
||||||
|
) {
|
||||||
|
radarrSettings = settings.radarr.find(
|
||||||
|
(radarr) => radarr.id === entity.serverId
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
`Request has an override server: ${radarrSettings?.name}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!radarrSettings) {
|
||||||
|
logger.warn(
|
||||||
|
`There is no default ${
|
||||||
|
entity.is4k ? '4K ' : ''
|
||||||
|
}Radarr server configured. Did you set any of your ${
|
||||||
|
entity.is4k ? '4K ' : ''
|
||||||
|
}Radarr servers as default?`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rootFolder = radarrSettings.activeDirectory;
|
||||||
|
let qualityProfile = radarrSettings.activeProfileId;
|
||||||
|
let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
entity.rootFolder &&
|
||||||
|
entity.rootFolder !== '' &&
|
||||||
|
entity.rootFolder !== radarrSettings.activeDirectory
|
||||||
|
) {
|
||||||
|
rootFolder = entity.rootFolder;
|
||||||
|
logger.info(`Request has an override root folder: ${rootFolder}`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
entity.profileId &&
|
||||||
|
entity.profileId !== radarrSettings.activeProfileId
|
||||||
|
) {
|
||||||
|
qualityProfile = entity.profileId;
|
||||||
|
logger.info(
|
||||||
|
`Request has an override quality profile ID: ${qualityProfile}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity.tags && !isEqual(entity.tags, radarrSettings.tags)) {
|
||||||
|
tags = entity.tags;
|
||||||
|
logger.info(`Request has override tags`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
tagIds: tags,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
const radarr = new RadarrAPI({
|
||||||
|
apiKey: radarrSettings.apiKey,
|
||||||
|
url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'),
|
||||||
|
});
|
||||||
|
const movie = await tmdb.getMovie({ movieId: entity.media.tmdbId });
|
||||||
|
|
||||||
|
const media = await mediaRepository.findOne({
|
||||||
|
where: { id: entity.media.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!media) {
|
||||||
|
logger.error('Media data not found', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (radarrSettings.tagRequests) {
|
||||||
|
let userTag = (await radarr.getTags()).find((v) =>
|
||||||
|
v.label.startsWith(entity.requestedBy.id + ' - ')
|
||||||
|
);
|
||||||
|
if (!userTag) {
|
||||||
|
logger.info(`Requester has no active tag. Creating new`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
userId: entity.requestedBy.id,
|
||||||
|
newTag:
|
||||||
|
entity.requestedBy.id + ' - ' + entity.requestedBy.displayName,
|
||||||
|
});
|
||||||
|
userTag = await radarr.createTag({
|
||||||
|
label:
|
||||||
|
entity.requestedBy.id + ' - ' + entity.requestedBy.displayName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (userTag.id) {
|
||||||
|
if (!tags?.find((v) => v === userTag?.id)) {
|
||||||
|
tags?.push(userTag.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`Requester has no tag and failed to add one`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
userId: entity.requestedBy.id,
|
||||||
|
radarrServer: radarrSettings.hostname + ':' + radarrSettings.port,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
media[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||||
|
) {
|
||||||
|
logger.warn('Media already exists, marking request as APPROVED', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
entity.status = MediaRequestStatus.APPROVED;
|
||||||
|
await requestRepository.save(entity);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const radarrMovieOptions: RadarrMovieOptions = {
|
||||||
|
profileId: qualityProfile,
|
||||||
|
qualityProfileId: qualityProfile,
|
||||||
|
rootFolderPath: rootFolder,
|
||||||
|
minimumAvailability: radarrSettings.minimumAvailability,
|
||||||
|
title: movie.title,
|
||||||
|
tmdbId: movie.id,
|
||||||
|
year: Number(movie.release_date.slice(0, 4)),
|
||||||
|
monitored: true,
|
||||||
|
tags,
|
||||||
|
searchNow: !radarrSettings.preventSearch,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run entity asynchronously so we don't wait for it on the UI side
|
||||||
|
radarr
|
||||||
|
.addMovie(radarrMovieOptions)
|
||||||
|
.then(async (radarrMovie) => {
|
||||||
|
// We grab media again here to make sure we have the latest version of it
|
||||||
|
const media = await mediaRepository.findOne({
|
||||||
|
where: { id: entity.media.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!media) {
|
||||||
|
throw new Error('Media data not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
media[entity.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||||
|
radarrMovie.id;
|
||||||
|
media[
|
||||||
|
entity.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
|
||||||
|
] = radarrMovie.titleSlug;
|
||||||
|
media[entity.is4k ? 'serviceId4k' : 'serviceId'] =
|
||||||
|
radarrSettings?.id;
|
||||||
|
await mediaRepository.save(media);
|
||||||
|
})
|
||||||
|
.catch(async () => {
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
|
entity.status = MediaRequestStatus.FAILED;
|
||||||
|
requestRepository.save(entity);
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
radarrMovieOptions,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
MediaRequest.sendNotification(
|
||||||
|
entity,
|
||||||
|
media,
|
||||||
|
Notification.MEDIA_FAILED
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
radarr.clearCache({
|
||||||
|
tmdbId: movie.id,
|
||||||
|
externalId: entity.is4k
|
||||||
|
? media.externalServiceId4k
|
||||||
|
: media.externalServiceId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
logger.info('Sent request to Radarr', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Something went wrong sending request to Radarr', {
|
||||||
|
label: 'Media Request',
|
||||||
|
errorMessage: e.message,
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
});
|
||||||
|
throw new Error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendToSonarr(entity: MediaRequest): Promise<void> {
|
||||||
|
if (
|
||||||
|
entity.status === MediaRequestStatus.APPROVED &&
|
||||||
|
entity.type === MediaType.TV
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const settings = getSettings();
|
||||||
|
if (settings.sonarr.length === 0 && !settings.sonarr[0]) {
|
||||||
|
logger.warn(
|
||||||
|
'No Sonarr server configured, skipping request processing',
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sonarrSettings = settings.sonarr.find(
|
||||||
|
(sonarr) => sonarr.isDefault && sonarr.is4k === entity.is4k
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
entity.serverId !== null &&
|
||||||
|
entity.serverId >= 0 &&
|
||||||
|
sonarrSettings?.id !== entity.serverId
|
||||||
|
) {
|
||||||
|
sonarrSettings = settings.sonarr.find(
|
||||||
|
(sonarr) => sonarr.id === entity.serverId
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
`Request has an override server: ${sonarrSettings?.name}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sonarrSettings) {
|
||||||
|
logger.warn(
|
||||||
|
`There is no default ${
|
||||||
|
entity.is4k ? '4K ' : ''
|
||||||
|
}Sonarr server configured. Did you set any of your ${
|
||||||
|
entity.is4k ? '4K ' : ''
|
||||||
|
}Sonarr servers as default?`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const media = await mediaRepository.findOne({
|
||||||
|
where: { id: entity.media.id },
|
||||||
|
relations: { requests: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!media) {
|
||||||
|
throw new Error('Media data not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
media[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||||
|
) {
|
||||||
|
logger.warn('Media already exists, marking request as APPROVED', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
entity.status = MediaRequestStatus.APPROVED;
|
||||||
|
await requestRepository.save(entity);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
const sonarr = new SonarrAPI({
|
||||||
|
apiKey: sonarrSettings.apiKey,
|
||||||
|
url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
|
||||||
|
});
|
||||||
|
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||||
|
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
|
||||||
|
|
||||||
|
if (!tvdbId) {
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
await mediaRepository.remove(media);
|
||||||
|
await requestRepository.remove(entity);
|
||||||
|
throw new Error('TVDB ID not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
let seriesType: SonarrSeries['seriesType'] = 'standard';
|
||||||
|
|
||||||
|
// Change series type to anime if the anime keyword is present on tmdb
|
||||||
|
if (
|
||||||
|
series.keywords.results.some(
|
||||||
|
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
seriesType = sonarrSettings.animeSeriesType ?? 'anime';
|
||||||
|
}
|
||||||
|
|
||||||
|
let rootFolder =
|
||||||
|
seriesType === 'anime' && sonarrSettings.activeAnimeDirectory
|
||||||
|
? sonarrSettings.activeAnimeDirectory
|
||||||
|
: sonarrSettings.activeDirectory;
|
||||||
|
let qualityProfile =
|
||||||
|
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
|
||||||
|
? sonarrSettings.activeAnimeProfileId
|
||||||
|
: sonarrSettings.activeProfileId;
|
||||||
|
let languageProfile =
|
||||||
|
seriesType === 'anime' && sonarrSettings.activeAnimeLanguageProfileId
|
||||||
|
? sonarrSettings.activeAnimeLanguageProfileId
|
||||||
|
: sonarrSettings.activeLanguageProfileId;
|
||||||
|
let tags =
|
||||||
|
seriesType === 'anime'
|
||||||
|
? sonarrSettings.animeTags
|
||||||
|
? [...sonarrSettings.animeTags]
|
||||||
|
: []
|
||||||
|
: sonarrSettings.tags
|
||||||
|
? [...sonarrSettings.tags]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
entity.rootFolder &&
|
||||||
|
entity.rootFolder !== '' &&
|
||||||
|
entity.rootFolder !== rootFolder
|
||||||
|
) {
|
||||||
|
rootFolder = entity.rootFolder;
|
||||||
|
logger.info(`Request has an override root folder: ${rootFolder}`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity.profileId && entity.profileId !== qualityProfile) {
|
||||||
|
qualityProfile = entity.profileId;
|
||||||
|
logger.info(
|
||||||
|
`Request has an override quality profile ID: ${qualityProfile}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
entity.languageProfileId &&
|
||||||
|
entity.languageProfileId !== languageProfile
|
||||||
|
) {
|
||||||
|
languageProfile = entity.languageProfileId;
|
||||||
|
logger.info(
|
||||||
|
`Request has an override language profile ID: ${languageProfile}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity.tags && !isEqual(entity.tags, tags)) {
|
||||||
|
tags = entity.tags;
|
||||||
|
logger.info(`Request has override tags`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
tagIds: tags,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sonarrSettings.tagRequests) {
|
||||||
|
let userTag = (await sonarr.getTags()).find((v) =>
|
||||||
|
v.label.startsWith(entity.requestedBy.id + ' - ')
|
||||||
|
);
|
||||||
|
if (!userTag) {
|
||||||
|
logger.info(`Requester has no active tag. Creating new`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
userId: entity.requestedBy.id,
|
||||||
|
newTag:
|
||||||
|
entity.requestedBy.id + ' - ' + entity.requestedBy.displayName,
|
||||||
|
});
|
||||||
|
userTag = await sonarr.createTag({
|
||||||
|
label:
|
||||||
|
entity.requestedBy.id + ' - ' + entity.requestedBy.displayName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (userTag.id) {
|
||||||
|
if (!tags?.find((v) => v === userTag?.id)) {
|
||||||
|
tags?.push(userTag.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`Requester has no tag and failed to add one`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
userId: entity.requestedBy.id,
|
||||||
|
sonarrServer: sonarrSettings.hostname + ':' + sonarrSettings.port,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sonarrSeriesOptions: AddSeriesOptions = {
|
||||||
|
profileId: qualityProfile,
|
||||||
|
languageProfileId: languageProfile,
|
||||||
|
rootFolderPath: rootFolder,
|
||||||
|
title: series.name,
|
||||||
|
tvdbid: tvdbId,
|
||||||
|
seasons: entity.seasons.map((season) => season.seasonNumber),
|
||||||
|
seasonFolder: sonarrSettings.enableSeasonFolders,
|
||||||
|
seriesType,
|
||||||
|
tags,
|
||||||
|
monitored: true,
|
||||||
|
searchNow: !sonarrSettings.preventSearch,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run entity asynchronously so we don't wait for it on the UI side
|
||||||
|
sonarr
|
||||||
|
.addSeries(sonarrSeriesOptions)
|
||||||
|
.then(async (sonarrSeries) => {
|
||||||
|
// We grab media again here to make sure we have the latest version of it
|
||||||
|
const media = await mediaRepository.findOne({
|
||||||
|
where: { id: entity.media.id },
|
||||||
|
relations: { requests: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!media) {
|
||||||
|
throw new Error('Media data not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
media[entity.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||||
|
sonarrSeries.id;
|
||||||
|
media[
|
||||||
|
entity.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
|
||||||
|
] = sonarrSeries.titleSlug;
|
||||||
|
media[entity.is4k ? 'serviceId4k' : 'serviceId'] =
|
||||||
|
sonarrSettings?.id;
|
||||||
|
await mediaRepository.save(media);
|
||||||
|
})
|
||||||
|
.catch(async () => {
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
|
entity.status = MediaRequestStatus.FAILED;
|
||||||
|
requestRepository.save(entity);
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
'Something went wrong sending series request to Sonarr, marking status as FAILED',
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
sonarrSeriesOptions,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
MediaRequest.sendNotification(
|
||||||
|
entity,
|
||||||
|
media,
|
||||||
|
Notification.MEDIA_FAILED
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
sonarr.clearCache({
|
||||||
|
tvdbId,
|
||||||
|
externalId: entity.is4k
|
||||||
|
? media.externalServiceId4k
|
||||||
|
: media.externalServiceId,
|
||||||
|
title: series.name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
logger.info('Sent request to Sonarr', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Something went wrong sending request to Sonarr', {
|
||||||
|
label: 'Media Request',
|
||||||
|
errorMessage: e.message,
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
});
|
||||||
|
throw new Error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateParentStatus(entity: MediaRequest): Promise<void> {
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const media = await mediaRepository.findOne({
|
||||||
|
where: { id: entity.media.id },
|
||||||
|
relations: { requests: true },
|
||||||
|
});
|
||||||
|
if (!media) {
|
||||||
|
logger.error('Media data not found', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||||
|
if (
|
||||||
|
entity.status === MediaRequestStatus.APPROVED &&
|
||||||
|
// Do not update the status if the item is already partially available or available
|
||||||
|
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE &&
|
||||||
|
media[entity.is4k ? 'status4k' : 'status'] !==
|
||||||
|
MediaStatus.PARTIALLY_AVAILABLE &&
|
||||||
|
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.PROCESSING
|
||||||
|
) {
|
||||||
|
media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.PROCESSING;
|
||||||
|
mediaRepository.save(media);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
media.mediaType === MediaType.MOVIE &&
|
||||||
|
entity.status === MediaRequestStatus.DECLINED &&
|
||||||
|
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED
|
||||||
|
) {
|
||||||
|
media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||||
|
mediaRepository.save(media);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the media type is TV, and we are declining a request,
|
||||||
|
* we must check if its the only pending request and that
|
||||||
|
* there the current media status is just pending (meaning no
|
||||||
|
* other requests have yet to be approved)
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
media.mediaType === MediaType.TV &&
|
||||||
|
entity.status === MediaRequestStatus.DECLINED &&
|
||||||
|
media.requests.filter(
|
||||||
|
(request) => request.status === MediaRequestStatus.PENDING
|
||||||
|
).length === 0 &&
|
||||||
|
media[entity.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING &&
|
||||||
|
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED
|
||||||
|
) {
|
||||||
|
media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||||
|
mediaRepository.save(media);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approve child seasons if parent is approved
|
||||||
|
if (
|
||||||
|
media.mediaType === MediaType.TV &&
|
||||||
|
entity.status === MediaRequestStatus.APPROVED
|
||||||
|
) {
|
||||||
|
entity.seasons.forEach((season) => {
|
||||||
|
season.status = MediaRequestStatus.APPROVED;
|
||||||
|
seasonRequestRepository.save(season);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async handleRemoveParentUpdate(
|
||||||
|
manager: EntityManager,
|
||||||
|
entity: MediaRequest
|
||||||
|
): Promise<void> {
|
||||||
|
const fullMedia = await manager.findOneOrFail(Media, {
|
||||||
|
where: { id: entity.media.id },
|
||||||
|
relations: { requests: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fullMedia) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!fullMedia.requests.some((request) => !request.is4k) &&
|
||||||
|
fullMedia.status !== MediaStatus.AVAILABLE
|
||||||
|
) {
|
||||||
|
fullMedia.status = MediaStatus.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!fullMedia.requests.some((request) => request.is4k) &&
|
||||||
|
fullMedia.status4k !== MediaStatus.AVAILABLE
|
||||||
|
) {
|
||||||
|
fullMedia.status4k = MediaStatus.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.save(fullMedia);
|
||||||
|
}
|
||||||
|
|
||||||
|
public afterUpdate(event: UpdateEvent<MediaRequest>): void {
|
||||||
|
if (!event.entity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendToRadarr(event.entity as MediaRequest);
|
||||||
|
this.sendToSonarr(event.entity as MediaRequest);
|
||||||
|
|
||||||
|
this.updateParentStatus(event.entity as MediaRequest);
|
||||||
|
|
||||||
|
if (event.entity.status === MediaRequestStatus.COMPLETED) {
|
||||||
|
if (event.entity.media.mediaType === MediaType.MOVIE) {
|
||||||
|
this.notifyAvailableMovie(event.entity as MediaRequest);
|
||||||
|
}
|
||||||
|
if (event.entity.media.mediaType === MediaType.TV) {
|
||||||
|
this.notifyAvailableSeries(event.entity as MediaRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public afterInsert(event: InsertEvent<MediaRequest>): void {
|
||||||
|
if (!event.entity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendToRadarr(event.entity as MediaRequest);
|
||||||
|
this.sendToSonarr(event.entity as MediaRequest);
|
||||||
|
|
||||||
|
this.updateParentStatus(event.entity as MediaRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async afterRemove(event: RemoveEvent<MediaRequest>): Promise<void> {
|
||||||
|
if (!event.entity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.handleRemoveParentUpdate(
|
||||||
|
event.manager as EntityManager,
|
||||||
|
event.entity as MediaRequest
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public listenTo(): typeof MediaRequest {
|
||||||
|
return MediaRequest;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import TheMovieDb from '@server/api/themoviedb';
|
|
||||||
import {
|
import {
|
||||||
MediaRequestStatus,
|
MediaRequestStatus,
|
||||||
MediaStatus,
|
MediaStatus,
|
||||||
@@ -8,172 +7,12 @@ import { getRepository } from '@server/datasource';
|
|||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
import { MediaRequest } from '@server/entity/MediaRequest';
|
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
import Season from '@server/entity/Season';
|
import Season from '@server/entity/Season';
|
||||||
import notificationManager, { Notification } from '@server/lib/notifications';
|
import SeasonRequest from '@server/entity/SeasonRequest';
|
||||||
import logger from '@server/logger';
|
|
||||||
import { truncate } from 'lodash';
|
|
||||||
import type { EntitySubscriberInterface, UpdateEvent } from 'typeorm';
|
import type { EntitySubscriberInterface, UpdateEvent } from 'typeorm';
|
||||||
import { EventSubscriber, In, Not } from 'typeorm';
|
import { EventSubscriber } from 'typeorm';
|
||||||
|
|
||||||
@EventSubscriber()
|
@EventSubscriber()
|
||||||
export class MediaSubscriber implements EntitySubscriberInterface<Media> {
|
export class MediaSubscriber implements EntitySubscriberInterface<Media> {
|
||||||
private async notifyAvailableMovie(
|
|
||||||
entity: Media,
|
|
||||||
dbEntity: Media,
|
|
||||||
is4k: boolean
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
entity[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE &&
|
|
||||||
dbEntity[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE
|
|
||||||
) {
|
|
||||||
if (entity.mediaType === MediaType.MOVIE) {
|
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
const relatedRequests = await requestRepository.find({
|
|
||||||
where: {
|
|
||||||
media: {
|
|
||||||
id: entity.id,
|
|
||||||
},
|
|
||||||
is4k,
|
|
||||||
status: Not(MediaRequestStatus.DECLINED),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (relatedRequests.length > 0) {
|
|
||||||
const tmdb = new TheMovieDb();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const movie = await tmdb.getMovie({ movieId: entity.tmdbId });
|
|
||||||
|
|
||||||
relatedRequests.forEach((request) => {
|
|
||||||
notificationManager.sendNotification(
|
|
||||||
Notification.MEDIA_AVAILABLE,
|
|
||||||
{
|
|
||||||
event: `${is4k ? '4K ' : ''}Movie Request Now Available`,
|
|
||||||
notifyAdmin: false,
|
|
||||||
notifySystem: true,
|
|
||||||
notifyUser: request.requestedBy,
|
|
||||||
subject: `${movie.title}${
|
|
||||||
movie.release_date
|
|
||||||
? ` (${movie.release_date.slice(0, 4)})`
|
|
||||||
: ''
|
|
||||||
}`,
|
|
||||||
message: truncate(movie.overview, {
|
|
||||||
length: 500,
|
|
||||||
separator: /\s/,
|
|
||||||
omission: '…',
|
|
||||||
}),
|
|
||||||
media: entity,
|
|
||||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
|
||||||
request,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('Something went wrong sending media notification(s)', {
|
|
||||||
label: 'Notifications',
|
|
||||||
errorMessage: e.message,
|
|
||||||
mediaId: entity.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async notifyAvailableSeries(
|
|
||||||
entity: Media,
|
|
||||||
dbEntity: Media,
|
|
||||||
is4k: boolean
|
|
||||||
) {
|
|
||||||
const seasonRepository = getRepository(Season);
|
|
||||||
const newAvailableSeasons = entity.seasons
|
|
||||||
.filter(
|
|
||||||
(season) =>
|
|
||||||
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
|
||||||
)
|
|
||||||
.map((season) => season.seasonNumber);
|
|
||||||
const oldSeasonIds = dbEntity.seasons.map((season) => season.id);
|
|
||||||
const oldSeasons = await seasonRepository.findBy({ id: In(oldSeasonIds) });
|
|
||||||
const oldAvailableSeasons = oldSeasons
|
|
||||||
.filter(
|
|
||||||
(season) =>
|
|
||||||
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
|
||||||
)
|
|
||||||
.map((season) => season.seasonNumber);
|
|
||||||
|
|
||||||
const changedSeasons = newAvailableSeasons.filter(
|
|
||||||
(seasonNumber) => !oldAvailableSeasons.includes(seasonNumber)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (changedSeasons.length > 0) {
|
|
||||||
const tmdb = new TheMovieDb();
|
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
const processedSeasons: number[] = [];
|
|
||||||
|
|
||||||
for (const changedSeasonNumber of changedSeasons) {
|
|
||||||
const requests = await requestRepository.find({
|
|
||||||
where: {
|
|
||||||
media: {
|
|
||||||
id: entity.id,
|
|
||||||
},
|
|
||||||
is4k,
|
|
||||||
status: Not(MediaRequestStatus.DECLINED),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const request = requests.find(
|
|
||||||
(request) =>
|
|
||||||
// Check if the season is complete AND it contains the current season that was just marked available
|
|
||||||
request.seasons.every((season) =>
|
|
||||||
newAvailableSeasons.includes(season.seasonNumber)
|
|
||||||
) &&
|
|
||||||
request.seasons.some(
|
|
||||||
(season) => season.seasonNumber === changedSeasonNumber
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (request && !processedSeasons.includes(changedSeasonNumber)) {
|
|
||||||
processedSeasons.push(
|
|
||||||
...request.seasons.map((season) => season.seasonNumber)
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const tv = await tmdb.getTvShow({ tvId: entity.tmdbId });
|
|
||||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
|
||||||
event: `${is4k ? '4K ' : ''}Series Request Now Available`,
|
|
||||||
subject: `${tv.name}${
|
|
||||||
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
|
||||||
}`,
|
|
||||||
message: truncate(tv.overview, {
|
|
||||||
length: 500,
|
|
||||||
separator: /\s/,
|
|
||||||
omission: '…',
|
|
||||||
}),
|
|
||||||
notifyAdmin: false,
|
|
||||||
notifySystem: true,
|
|
||||||
notifyUser: request.requestedBy,
|
|
||||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
|
||||||
media: entity,
|
|
||||||
extra: [
|
|
||||||
{
|
|
||||||
name: 'Requested Seasons',
|
|
||||||
value: request.seasons
|
|
||||||
.map((season) => season.seasonNumber)
|
|
||||||
.join(', '),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
request,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('Something went wrong sending media notification(s)', {
|
|
||||||
label: 'Notifications',
|
|
||||||
errorMessage: e.message,
|
|
||||||
mediaId: entity.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async updateChildRequestStatus(event: Media, is4k: boolean) {
|
private async updateChildRequestStatus(event: Media, is4k: boolean) {
|
||||||
const requestRepository = getRepository(MediaRequest);
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
@@ -192,57 +31,101 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public beforeUpdate(event: UpdateEvent<Media>): void {
|
private async updateRelatedMediaRequest(
|
||||||
|
event: Media,
|
||||||
|
databaseEvent: Media,
|
||||||
|
is4k: boolean
|
||||||
|
) {
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||||
|
|
||||||
|
const relatedRequests = await requestRepository.find({
|
||||||
|
relations: {
|
||||||
|
media: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
media: { id: event.id },
|
||||||
|
status: MediaRequestStatus.APPROVED,
|
||||||
|
is4k,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check the media entity status and if available
|
||||||
|
// or deleted, set the related request to completed
|
||||||
|
if (relatedRequests.length > 0) {
|
||||||
|
const completedRequests: MediaRequest[] = [];
|
||||||
|
|
||||||
|
for (const request of relatedRequests) {
|
||||||
|
let shouldComplete = false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(event[request.is4k ? 'status4k' : 'status'] ===
|
||||||
|
MediaStatus.AVAILABLE ||
|
||||||
|
event[request.is4k ? 'status4k' : 'status'] ===
|
||||||
|
MediaStatus.DELETED) &&
|
||||||
|
event.mediaType === MediaType.MOVIE
|
||||||
|
) {
|
||||||
|
shouldComplete = true;
|
||||||
|
} else if (event.mediaType === 'tv') {
|
||||||
|
const allSeasonResults = await Promise.all(
|
||||||
|
request.seasons.map(async (requestSeason) => {
|
||||||
|
const matchingSeason = event.seasons.find(
|
||||||
|
(mediaSeason) =>
|
||||||
|
mediaSeason.seasonNumber === requestSeason.seasonNumber
|
||||||
|
);
|
||||||
|
const matchingOldSeason = databaseEvent.seasons.find(
|
||||||
|
(oldSeason) =>
|
||||||
|
oldSeason.seasonNumber === requestSeason.seasonNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!matchingSeason) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSeasonStatus =
|
||||||
|
matchingSeason[request.is4k ? 'status4k' : 'status'];
|
||||||
|
const previousSeasonStatus =
|
||||||
|
matchingOldSeason?.[request.is4k ? 'status4k' : 'status'];
|
||||||
|
|
||||||
|
const hasStatusChanged =
|
||||||
|
currentSeasonStatus !== previousSeasonStatus;
|
||||||
|
|
||||||
|
const shouldUpdate =
|
||||||
|
(hasStatusChanged ||
|
||||||
|
requestSeason.status === MediaRequestStatus.COMPLETED) &&
|
||||||
|
(currentSeasonStatus === MediaStatus.AVAILABLE ||
|
||||||
|
currentSeasonStatus === MediaStatus.DELETED);
|
||||||
|
|
||||||
|
if (shouldUpdate) {
|
||||||
|
requestSeason.status = MediaRequestStatus.COMPLETED;
|
||||||
|
await seasonRequestRepository.save(requestSeason);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const allSeasonsReady = allSeasonResults.every((result) => result);
|
||||||
|
shouldComplete = allSeasonsReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldComplete) {
|
||||||
|
request.status = MediaRequestStatus.COMPLETED;
|
||||||
|
completedRequests.push(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await requestRepository.save(completedRequests);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async beforeUpdate(event: UpdateEvent<Media>): Promise<void> {
|
||||||
if (!event.entity) {
|
if (!event.entity) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
event.entity.mediaType === MediaType.MOVIE &&
|
|
||||||
event.entity.status === MediaStatus.AVAILABLE
|
|
||||||
) {
|
|
||||||
this.notifyAvailableMovie(
|
|
||||||
event.entity as Media,
|
|
||||||
event.databaseEntity,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.entity.mediaType === MediaType.MOVIE &&
|
|
||||||
event.entity.status4k === MediaStatus.AVAILABLE
|
|
||||||
) {
|
|
||||||
this.notifyAvailableMovie(
|
|
||||||
event.entity as Media,
|
|
||||||
event.databaseEntity,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.entity.mediaType === MediaType.TV &&
|
|
||||||
(event.entity.status === MediaStatus.AVAILABLE ||
|
|
||||||
event.entity.status === MediaStatus.PARTIALLY_AVAILABLE)
|
|
||||||
) {
|
|
||||||
this.notifyAvailableSeries(
|
|
||||||
event.entity as Media,
|
|
||||||
event.databaseEntity,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.entity.mediaType === MediaType.TV &&
|
|
||||||
(event.entity.status4k === MediaStatus.AVAILABLE ||
|
|
||||||
event.entity.status4k === MediaStatus.PARTIALLY_AVAILABLE)
|
|
||||||
) {
|
|
||||||
this.notifyAvailableSeries(
|
|
||||||
event.entity as Media,
|
|
||||||
event.databaseEntity,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event.entity.status === MediaStatus.AVAILABLE &&
|
event.entity.status === MediaStatus.AVAILABLE &&
|
||||||
event.databaseEntity.status === MediaStatus.PENDING
|
event.databaseEntity.status === MediaStatus.PENDING
|
||||||
@@ -256,6 +139,65 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
|
|||||||
) {
|
) {
|
||||||
this.updateChildRequestStatus(event.entity as Media, true);
|
this.updateChildRequestStatus(event.entity as Media, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manually load related seasons into databaseEntity
|
||||||
|
// for seasonStatusCheck in afterUpdate
|
||||||
|
const seasons = await event.manager
|
||||||
|
.getRepository(Season)
|
||||||
|
.createQueryBuilder('season')
|
||||||
|
.leftJoin('season.media', 'media')
|
||||||
|
.where('media.id = :id', { id: event.databaseEntity.id })
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
event.databaseEntity.seasons = seasons;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async afterUpdate(event: UpdateEvent<Media>): Promise<void> {
|
||||||
|
if (!event.entity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validStatuses = [
|
||||||
|
MediaStatus.PARTIALLY_AVAILABLE,
|
||||||
|
MediaStatus.AVAILABLE,
|
||||||
|
MediaStatus.DELETED,
|
||||||
|
];
|
||||||
|
|
||||||
|
const seasonStatusCheck = (is4k: boolean) => {
|
||||||
|
return event.entity?.seasons?.some((season: Season, index: number) => {
|
||||||
|
const previousSeason = event.databaseEntity.seasons[index];
|
||||||
|
|
||||||
|
return (
|
||||||
|
season[is4k ? 'status4k' : 'status'] !==
|
||||||
|
previousSeason?.[is4k ? 'status4k' : 'status']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
(event.entity.status !== event.databaseEntity?.status ||
|
||||||
|
(event.entity.mediaType === MediaType.TV &&
|
||||||
|
seasonStatusCheck(false))) &&
|
||||||
|
validStatuses.includes(event.entity.status)
|
||||||
|
) {
|
||||||
|
this.updateRelatedMediaRequest(
|
||||||
|
event.entity as Media,
|
||||||
|
event.databaseEntity as Media,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(event.entity.status4k !== event.databaseEntity?.status4k ||
|
||||||
|
(event.entity.mediaType === MediaType.TV && seasonStatusCheck(true))) &&
|
||||||
|
validStatuses.includes(event.entity.status4k)
|
||||||
|
) {
|
||||||
|
this.updateRelatedMediaRequest(
|
||||||
|
event.entity as Media,
|
||||||
|
event.databaseEntity as Media,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public listenTo(): typeof Media {
|
public listenTo(): typeof Media {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { ProxySettings } from '@server/lib/settings';
|
import type { ProxySettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { HttpProxyAgent } from 'http-proxy-agent';
|
||||||
|
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||||
import type { Dispatcher } from 'undici';
|
import type { Dispatcher } from 'undici';
|
||||||
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
|
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
|
||||||
|
|
||||||
@@ -54,17 +56,33 @@ export default async function createCustomProxyAgent(
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const proxyUrl = `${proxySettings.useSsl ? 'https' : 'http'}://${
|
||||||
|
proxySettings.hostname
|
||||||
|
}:${proxySettings.port}`;
|
||||||
const proxyAgent = new ProxyAgent({
|
const proxyAgent = new ProxyAgent({
|
||||||
uri:
|
uri: proxyUrl,
|
||||||
(proxySettings.useSsl ? 'https://' : 'http://') +
|
|
||||||
proxySettings.hostname +
|
|
||||||
':' +
|
|
||||||
proxySettings.port,
|
|
||||||
token,
|
token,
|
||||||
keepAliveTimeout: 5000,
|
keepAliveTimeout: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
setGlobalDispatcher(proxyAgent.compose(noProxyInterceptor));
|
setGlobalDispatcher(proxyAgent.compose(noProxyInterceptor));
|
||||||
|
|
||||||
|
axios.defaults.httpAgent = new HttpProxyAgent(proxyUrl, {
|
||||||
|
headers: token ? { 'proxy-authorization': token } : undefined,
|
||||||
|
});
|
||||||
|
axios.defaults.httpsAgent = new HttpsProxyAgent(proxyUrl, {
|
||||||
|
headers: token ? { 'proxy-authorization': token } : undefined,
|
||||||
|
});
|
||||||
|
axios.interceptors.request.use((config) => {
|
||||||
|
const url = config.baseURL
|
||||||
|
? new URL(config.baseURL + (config.url || ''))
|
||||||
|
: config.url;
|
||||||
|
if (url && skipUrl(url)) {
|
||||||
|
config.httpAgent = false;
|
||||||
|
config.httpsAgent = false;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Failed to connect to the proxy: ' + e.message, {
|
logger.error('Failed to connect to the proxy: ' + e.message, {
|
||||||
label: 'Proxy',
|
label: 'Proxy',
|
||||||
|
|||||||
1
src/assets/extlogos/ntfy.svg
Normal file
1
src/assets/extlogos/ntfy.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50"><g fill="currentColor"><path d="M50.4 46.883c-9.168 0-17.023 7.214-17.023 16.387v.007l.09 71.37-2.303 16.992 31.313-8.319h77.841c9.17 0 17.024-7.224 17.024-16.396V63.27c0-9.17-7.85-16.383-17.016-16.387h-.008zm0 11.566h89.926c3.222.004 5.45 2.347 5.45 4.82v63.655c0 2.475-2.232 4.82-5.457 4.82h-79.54l-15.908 4.807.162-.938-.088-72.343c0-2.476 2.23-4.82 5.455-4.82z" transform="scale(.26458)"/><path d="M88.2 95.309H64.92c-1.601 0-2.91 1.236-2.91 2.746l.022 18.602-.435 2.506 6.231-1.881H88.2c1.6 0 2.91-1.236 2.91-2.747v-16.48c0-1.51-1.31-2.746-2.91-2.746z" transform="translate(-51.147 -81.516)"/><path d="M50.4 46.883c-9.168 0-17.023 7.214-17.023 16.387v.007l.09 71.37-2.303 16.992 31.313-8.319h77.841c9.17 0 17.024-7.224 17.024-16.396V63.27c0-9.17-7.85-16.383-17.016-16.387h-.008zm0 11.566h89.926c3.222.004 5.45 2.347 5.45 4.82v63.655c0 2.475-2.232 4.82-5.457 4.82h-79.54l-15.908 4.807.162-.938-.088-72.343c0-2.476 2.23-4.82 5.455-4.82z" transform="scale(.26458)"/><path d="M62.57 116.77v-1.312l3.28-1.459q.159-.068.306-.102.158-.045.283-.068l.271-.022v-.09q-.136-.012-.271-.046-.125-.023-.283-.057-.147-.045-.306-.113l-3.28-1.459v-1.323l5.068 2.319v1.413z" transform="matrix(1.45366 0 0 1.72815 -75.122 -171.953)"/><path d="M62.309 110.31v1.903l3.437 1.53.022.007-.022.008-3.437 1.53v1.892l.37-.17 5.221-2.39v-1.75zm.525.817 4.541 2.08v1.076l-4.541 2.078v-.732l3.12-1.389.003-.002a1.56 1.56 0 0 1 .258-.086h.006l.008-.002c.094-.027.176-.047.246-.06l.498-.041v-.574l-.24-.02a1.411 1.411 0 0 1-.231-.04l-.008-.001-.008-.002a9.077 9.077 0 0 1-.263-.053 2.781 2.781 0 0 1-.266-.097l-.004-.002-3.119-1.39z" transform="matrix(1.45366 0 0 1.72815 -75.122 -171.953)"/><path d="M69.171 117.754h5.43v1.278h-5.43Z" transform="matrix(1.44935 0 0 1.66414 -74.104 -166.906)"/><path d="M68.908 117.492v1.802h5.955v-1.802zm.526.524h4.904v.754h-4.904z" transform="matrix(1.44935 0 0 1.66414 -74.104 -166.906)"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -6,7 +6,7 @@ const imageLoader: ImageLoader = ({ src }) => src;
|
|||||||
|
|
||||||
export type CachedImageProps = ImageProps & {
|
export type CachedImageProps = ImageProps & {
|
||||||
src: string;
|
src: string;
|
||||||
type: 'tmdb' | 'avatar';
|
type: 'tmdb' | 'avatar' | 'tvdb';
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,7 +22,15 @@ const CachedImage = ({ src, type, ...props }: CachedImageProps) => {
|
|||||||
// tmdb stuff
|
// tmdb stuff
|
||||||
imageUrl =
|
imageUrl =
|
||||||
currentSettings.cacheImages && !src.startsWith('/')
|
currentSettings.cacheImages && !src.startsWith('/')
|
||||||
? src.replace(/^https:\/\/image\.tmdb\.org\//, '/imageproxy/')
|
? src.replace(/^https:\/\/image\.tmdb\.org\//, '/imageproxy/tmdb/')
|
||||||
|
: src;
|
||||||
|
} else if (type === 'tvdb') {
|
||||||
|
imageUrl =
|
||||||
|
currentSettings.cacheImages && !src.startsWith('/')
|
||||||
|
? src.replace(
|
||||||
|
/^https:\/\/artworks\.thetvdb\.com\//,
|
||||||
|
'/imageproxy/tvdb/'
|
||||||
|
)
|
||||||
: src;
|
: src;
|
||||||
} else if (type === 'avatar') {
|
} else if (type === 'avatar') {
|
||||||
// jellyfin avatar (if any)
|
// jellyfin avatar (if any)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ClockIcon,
|
ClockIcon,
|
||||||
EyeSlashIcon,
|
EyeSlashIcon,
|
||||||
MinusSmallIcon,
|
MinusSmallIcon,
|
||||||
|
TrashIcon,
|
||||||
} from '@heroicons/react/24/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
import { MediaStatus } from '@server/constants/media';
|
import { MediaStatus } from '@server/constants/media';
|
||||||
|
|
||||||
@@ -59,6 +60,10 @@ const StatusBadgeMini = ({
|
|||||||
);
|
);
|
||||||
indicatorIcon = <MinusSmallIcon />;
|
indicatorIcon = <MinusSmallIcon />;
|
||||||
break;
|
break;
|
||||||
|
case MediaStatus.DELETED:
|
||||||
|
badgeStyle.push('bg-red-500 border-red-400 ring-red-400 text-red-100');
|
||||||
|
indicatorIcon = <TrashIcon />;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inProgress) {
|
if (inProgress) {
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ const DiscoverMovies = () => {
|
|||||||
id="sortBy"
|
id="sortBy"
|
||||||
name="sortBy"
|
name="sortBy"
|
||||||
className="rounded-r-only"
|
className="rounded-r-only"
|
||||||
value={preparedFilters.sortBy}
|
value={preparedFilters.sortBy || SortOptions.PopularityDesc}
|
||||||
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
|
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value={SortOptions.PopularityDesc}>
|
<option value={SortOptions.PopularityDesc}>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import CachedImage from '@app/components/Common/CachedImage';
|
||||||
import Header from '@app/components/Common/Header';
|
import Header from '@app/components/Common/Header';
|
||||||
import ListView from '@app/components/Common/ListView';
|
import ListView from '@app/components/Common/ListView';
|
||||||
import PageTitle from '@app/components/Common/PageTitle';
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
@@ -7,7 +8,6 @@ import Error from '@app/pages/_error';
|
|||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type { TvNetwork } from '@server/models/common';
|
import type { TvNetwork } from '@server/models/common';
|
||||||
import type { TvResult } from '@server/models/Search';
|
import type { TvResult } from '@server/models/Search';
|
||||||
import Image from 'next/image';
|
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
@@ -49,7 +49,8 @@ const DiscoverTvNetwork = () => {
|
|||||||
<Header>
|
<Header>
|
||||||
{firstResultData?.network.logoPath ? (
|
{firstResultData?.network.logoPath ? (
|
||||||
<div className="relative mb-6 flex h-24 justify-center sm:h-32">
|
<div className="relative mb-6 flex h-24 justify-center sm:h-32">
|
||||||
<Image
|
<CachedImage
|
||||||
|
type="tmdb"
|
||||||
src={`https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`}
|
src={`https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`}
|
||||||
alt={firstResultData.network.name}
|
alt={firstResultData.network.name}
|
||||||
className="object-contain"
|
className="object-contain"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import CachedImage from '@app/components/Common/CachedImage';
|
||||||
import Header from '@app/components/Common/Header';
|
import Header from '@app/components/Common/Header';
|
||||||
import ListView from '@app/components/Common/ListView';
|
import ListView from '@app/components/Common/ListView';
|
||||||
import PageTitle from '@app/components/Common/PageTitle';
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
@@ -7,7 +8,6 @@ import Error from '@app/pages/_error';
|
|||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type { ProductionCompany } from '@server/models/common';
|
import type { ProductionCompany } from '@server/models/common';
|
||||||
import type { MovieResult } from '@server/models/Search';
|
import type { MovieResult } from '@server/models/Search';
|
||||||
import Image from 'next/image';
|
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
@@ -49,7 +49,8 @@ const DiscoverMovieStudio = () => {
|
|||||||
<Header>
|
<Header>
|
||||||
{firstResultData?.studio.logoPath ? (
|
{firstResultData?.studio.logoPath ? (
|
||||||
<div className="relative mb-6 flex h-24 justify-center sm:h-32">
|
<div className="relative mb-6 flex h-24 justify-center sm:h-32">
|
||||||
<Image
|
<CachedImage
|
||||||
|
type="tmdb"
|
||||||
src={`https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`}
|
src={`https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`}
|
||||||
alt={firstResultData.studio.name}
|
alt={firstResultData.studio.name}
|
||||||
className="object-contain"
|
className="object-contain"
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ const DiscoverTv = () => {
|
|||||||
id="sortBy"
|
id="sortBy"
|
||||||
name="sortBy"
|
name="sortBy"
|
||||||
className="rounded-r-only"
|
className="rounded-r-only"
|
||||||
value={preparedFilters.sortBy}
|
value={preparedFilters.sortBy || SortOptions.PopularityDesc}
|
||||||
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
|
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value={SortOptions.PopularityDesc}>
|
<option value={SortOptions.PopularityDesc}>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
GenreSelector,
|
GenreSelector,
|
||||||
KeywordSelector,
|
KeywordSelector,
|
||||||
StatusSelector,
|
StatusSelector,
|
||||||
|
USCertificationSelector,
|
||||||
WatchProviderSelector,
|
WatchProviderSelector,
|
||||||
} from '@app/components/Selector';
|
} from '@app/components/Selector';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
@@ -42,6 +43,7 @@ const messages = defineMessages('components.Discover.FilterSlideover', {
|
|||||||
streamingservices: 'Streaming Services',
|
streamingservices: 'Streaming Services',
|
||||||
voteCount: 'Number of votes between {minValue} and {maxValue}',
|
voteCount: 'Number of votes between {minValue} and {maxValue}',
|
||||||
status: 'Status',
|
status: 'Status',
|
||||||
|
certification: 'Content Rating',
|
||||||
});
|
});
|
||||||
|
|
||||||
type FilterSlideoverProps = {
|
type FilterSlideoverProps = {
|
||||||
@@ -190,6 +192,16 @@ const FilterSlideover = ({
|
|||||||
updateQueryParams('language', value);
|
updateQueryParams('language', value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<span className="text-lg font-semibold">
|
||||||
|
{intl.formatMessage(messages.certification)}
|
||||||
|
</span>
|
||||||
|
<USCertificationSelector
|
||||||
|
type={type}
|
||||||
|
certification={currentFilters.certification}
|
||||||
|
onChange={(params) => {
|
||||||
|
batchUpdateQueryParams(params);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<span className="text-lg font-semibold">
|
<span className="text-lg font-semibold">
|
||||||
{intl.formatMessage(messages.runtime)}
|
{intl.formatMessage(messages.runtime)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -109,6 +109,11 @@ export const QueryFilterOptions = z.object({
|
|||||||
watchRegion: z.string().optional(),
|
watchRegion: z.string().optional(),
|
||||||
watchProviders: z.string().optional(),
|
watchProviders: z.string().optional(),
|
||||||
status: z.string().optional(),
|
status: z.string().optional(),
|
||||||
|
certification: z.string().optional(),
|
||||||
|
certificationGte: z.string().optional(),
|
||||||
|
certificationLte: z.string().optional(),
|
||||||
|
certificationCountry: z.string().optional(),
|
||||||
|
certificationMode: z.enum(['exact', 'range']).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
||||||
@@ -192,6 +197,30 @@ export const prepareFilterValues = (
|
|||||||
filterValues.watchRegion = values.watchRegion;
|
filterValues.watchRegion = values.watchRegion;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (values.certification) {
|
||||||
|
filterValues.certification = values.certification;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.certificationGte) {
|
||||||
|
filterValues.certificationGte = values.certificationGte;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.certificationLte) {
|
||||||
|
filterValues.certificationLte = values.certificationLte;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.certificationCountry) {
|
||||||
|
filterValues.certificationCountry = values.certificationCountry;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.certificationMode) {
|
||||||
|
filterValues.certificationMode = values.certificationMode;
|
||||||
|
} else if (values.certification) {
|
||||||
|
filterValues.certificationMode = 'exact';
|
||||||
|
} else if (values.certificationGte || values.certificationLte) {
|
||||||
|
filterValues.certificationMode = 'range';
|
||||||
|
}
|
||||||
|
|
||||||
return filterValues;
|
return filterValues;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -223,6 +252,20 @@ export const countActiveFilters = (filterValues: FilterOptions): number => {
|
|||||||
delete clonedFilters.watchRegion;
|
delete clonedFilters.watchRegion;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
clonedFilters.certification ||
|
||||||
|
clonedFilters.certificationGte ||
|
||||||
|
clonedFilters.certificationLte ||
|
||||||
|
clonedFilters.certificationCountry
|
||||||
|
) {
|
||||||
|
totalCount += 1;
|
||||||
|
delete clonedFilters.certification;
|
||||||
|
delete clonedFilters.certificationGte;
|
||||||
|
delete clonedFilters.certificationLte;
|
||||||
|
delete clonedFilters.certificationCountry;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete clonedFilters.certificationMode;
|
||||||
totalCount += Object.keys(clonedFilters).length;
|
totalCount += Object.keys(clonedFilters).length;
|
||||||
|
|
||||||
return totalCount;
|
return totalCount;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Button from '@app/components/Common/Button';
|
|||||||
import CachedImage from '@app/components/Common/CachedImage';
|
import CachedImage from '@app/components/Common/CachedImage';
|
||||||
import Modal from '@app/components/Common/Modal';
|
import Modal from '@app/components/Common/Modal';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { Menu, Transition } from '@headlessui/react';
|
import { Menu, Transition } from '@headlessui/react';
|
||||||
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
|
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
|
||||||
@@ -207,13 +208,13 @@ const IssueComment = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsEditing(false)}
|
onClick={() => setIsEditing(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
{intl.formatMessage(globalMessages.cancel)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
disabled={!isValid || isSubmitting}
|
disabled={!isValid || isSubmitting}
|
||||||
>
|
>
|
||||||
Save Changes
|
{intl.formatMessage(globalMessages.save)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -222,7 +223,10 @@ const IssueComment = ({
|
|||||||
</Formik>
|
</Formik>
|
||||||
) : (
|
) : (
|
||||||
<div className="prose w-full max-w-full">
|
<div className="prose w-full max-w-full">
|
||||||
<ReactMarkdown skipHtml allowedElements={['p', 'em', 'strong']}>
|
<ReactMarkdown
|
||||||
|
skipHtml
|
||||||
|
allowedElements={['p', 'em', 'strong', 'ul', 'ol', 'li']}
|
||||||
|
>
|
||||||
{comment.message}
|
{comment.message}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import CachedImage from '@app/components/Common/CachedImage';
|
import CachedImage from '@app/components/Common/CachedImage';
|
||||||
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
|
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
|
||||||
import { useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { Menu, Transition } from '@headlessui/react';
|
import { Menu, Transition } from '@headlessui/react';
|
||||||
import {
|
import {
|
||||||
@@ -36,7 +36,7 @@ ForwardedLink.displayName = 'ForwardedLink';
|
|||||||
|
|
||||||
const UserDropdown = () => {
|
const UserDropdown = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { user, revalidate } = useUser();
|
const { user, revalidate, hasPermission } = useUser();
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
const response = await axios.post('/api/v1/auth/logout');
|
const response = await axios.post('/api/v1/auth/logout');
|
||||||
@@ -118,7 +118,14 @@ const UserDropdown = () => {
|
|||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
{({ active }) => (
|
{({ active }) => (
|
||||||
<ForwardedLink
|
<ForwardedLink
|
||||||
href={`/users/${user?.id}/requests?filter=all`}
|
href={
|
||||||
|
hasPermission(
|
||||||
|
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
|
||||||
|
{ type: 'or' }
|
||||||
|
)
|
||||||
|
? `/users/${user?.id}/requests?filter=all`
|
||||||
|
: '/requests'
|
||||||
|
}
|
||||||
className={`flex items-center rounded px-4 py-2 text-sm font-medium text-gray-200 transition duration-150 ease-in-out ${
|
className={`flex items-center rounded px-4 py-2 text-sm font-medium text-gray-200 transition duration-150 ease-in-out ${
|
||||||
active
|
active
|
||||||
? 'bg-gradient-to-br from-indigo-600 to-purple-600 text-white'
|
? 'bg-gradient-to-br from-indigo-600 to-purple-600 text-white'
|
||||||
|
|||||||
@@ -152,6 +152,9 @@ const ManageSlideOver = ({
|
|||||||
if (data.mediaInfo) {
|
if (data.mediaInfo) {
|
||||||
await axios.post(`/api/v1/media/${data.mediaInfo?.id}/available`, {
|
await axios.post(`/api/v1/media/${data.mediaInfo?.id}/available`, {
|
||||||
is4k,
|
is4k,
|
||||||
|
...(mediaType === 'tv' && {
|
||||||
|
seasons: data.seasons.filter((season) => season.seasonNumber !== 0),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
revalidate();
|
revalidate();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import CachedImage from '@app/components/Common/CachedImage';
|
||||||
import TitleCard from '@app/components/TitleCard';
|
import TitleCard from '@app/components/TitleCard';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { ArrowRightCircleIcon } from '@heroicons/react/24/solid';
|
import { ArrowRightCircleIcon } from '@heroicons/react/24/solid';
|
||||||
import Image from 'next/image';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useInView } from 'react-intersection-observer';
|
import { useInView } from 'react-intersection-observer';
|
||||||
@@ -60,7 +60,8 @@ const ShowMoreCard = ({ url, posters }: ShowMoreCardProps) => {
|
|||||||
<div className="relative z-10 grid h-full w-full grid-cols-2 items-center justify-center gap-2 opacity-30">
|
<div className="relative z-10 grid h-full w-full grid-cols-2 items-center justify-center gap-2 opacity-30">
|
||||||
{posters[0] && (
|
{posters[0] && (
|
||||||
<div className="">
|
<div className="">
|
||||||
<Image
|
<CachedImage
|
||||||
|
type="tmdb"
|
||||||
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[0]}`}
|
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[0]}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="rounded-md"
|
className="rounded-md"
|
||||||
@@ -71,7 +72,8 @@ const ShowMoreCard = ({ url, posters }: ShowMoreCardProps) => {
|
|||||||
)}
|
)}
|
||||||
{posters[1] && (
|
{posters[1] && (
|
||||||
<div className="">
|
<div className="">
|
||||||
<Image
|
<CachedImage
|
||||||
|
type="tmdb"
|
||||||
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[1]}`}
|
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[1]}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="rounded-md"
|
className="rounded-md"
|
||||||
@@ -82,7 +84,8 @@ const ShowMoreCard = ({ url, posters }: ShowMoreCardProps) => {
|
|||||||
)}
|
)}
|
||||||
{posters[2] && (
|
{posters[2] && (
|
||||||
<div className="">
|
<div className="">
|
||||||
<Image
|
<CachedImage
|
||||||
|
type="tmdb"
|
||||||
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[2]}`}
|
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[2]}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="rounded-md"
|
className="rounded-md"
|
||||||
@@ -93,7 +96,8 @@ const ShowMoreCard = ({ url, posters }: ShowMoreCardProps) => {
|
|||||||
)}
|
)}
|
||||||
{posters[3] && (
|
{posters[3] && (
|
||||||
<div className="">
|
<div className="">
|
||||||
<Image
|
<CachedImage
|
||||||
|
type="tmdb"
|
||||||
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[3]}`}
|
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[3]}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="rounded-md"
|
className="rounded-md"
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ const MediaSlider = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (settings.currentSettings.hideBlacklisted) {
|
||||||
|
titles = titles.filter(
|
||||||
|
(i) =>
|
||||||
|
(i.mediaType === 'movie' || i.mediaType === 'tv') &&
|
||||||
|
i.mediaInfo?.status !== MediaStatus.BLACKLISTED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
titles.length < 24 &&
|
titles.length < 24 &&
|
||||||
|
|||||||
@@ -210,10 +210,16 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
svg: <PlayIcon />,
|
svg: <PlayIcon />,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const trailerUrl = data.relatedVideos
|
|
||||||
|
const trailerVideo = data.relatedVideos
|
||||||
?.filter((r) => r.type === 'Trailer')
|
?.filter((r) => r.type === 'Trailer')
|
||||||
.sort((a, b) => a.size - b.size)
|
.sort((a, b) => a.size - b.size)
|
||||||
.pop()?.url;
|
.pop();
|
||||||
|
const trailerUrl =
|
||||||
|
trailerVideo?.site === 'YouTube' &&
|
||||||
|
settings.currentSettings.youtubeUrl != ''
|
||||||
|
? `${settings.currentSettings.youtubeUrl}${trailerVideo?.key}`
|
||||||
|
: trailerVideo?.url;
|
||||||
|
|
||||||
if (trailerUrl) {
|
if (trailerUrl) {
|
||||||
mediaLinks.push({
|
mediaLinks.push({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import TitleCard from '@app/components/TitleCard';
|
|||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { CircleStackIcon } from '@heroicons/react/24/solid';
|
||||||
import type { PersonCombinedCreditsResponse } from '@server/interfaces/api/personInterfaces';
|
import type { PersonCombinedCreditsResponse } from '@server/interfaces/api/personInterfaces';
|
||||||
import type { PersonDetails as PersonDetailsType } from '@server/models/Person';
|
import type { PersonDetails as PersonDetailsType } from '@server/models/Person';
|
||||||
import { groupBy } from 'lodash';
|
import { groupBy } from 'lodash';
|
||||||
@@ -25,9 +26,12 @@ const messages = defineMessages('components.PersonDetails', {
|
|||||||
ascharacter: 'as {character}',
|
ascharacter: 'as {character}',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type MediaType = 'all' | 'movie' | 'tv';
|
||||||
|
|
||||||
const PersonDetails = () => {
|
const PersonDetails = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [currentMediaType, setCurrentMediaType] = useState<string>('all');
|
||||||
const { data, error } = useSWR<PersonDetailsType>(
|
const { data, error } = useSWR<PersonDetailsType>(
|
||||||
`/api/v1/person/${router.query.personId}`
|
`/api/v1/person/${router.query.personId}`
|
||||||
);
|
);
|
||||||
@@ -39,7 +43,11 @@ const PersonDetails = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const sortedCast = useMemo(() => {
|
const sortedCast = useMemo(() => {
|
||||||
const grouped = groupBy(combinedCredits?.cast ?? [], 'id');
|
const filtered = (combinedCredits?.cast ?? []).filter(
|
||||||
|
(media) =>
|
||||||
|
currentMediaType === 'all' || media.mediaType === currentMediaType
|
||||||
|
);
|
||||||
|
const grouped = groupBy(filtered, 'id');
|
||||||
|
|
||||||
const reduced = Object.values(grouped).map((objs) => ({
|
const reduced = Object.values(grouped).map((objs) => ({
|
||||||
...objs[0],
|
...objs[0],
|
||||||
@@ -54,10 +62,14 @@ const PersonDetails = () => {
|
|||||||
}
|
}
|
||||||
return 1;
|
return 1;
|
||||||
});
|
});
|
||||||
}, [combinedCredits]);
|
}, [combinedCredits, currentMediaType]);
|
||||||
|
|
||||||
const sortedCrew = useMemo(() => {
|
const sortedCrew = useMemo(() => {
|
||||||
const grouped = groupBy(combinedCredits?.crew ?? [], 'id');
|
const filtered = (combinedCredits?.crew ?? []).filter(
|
||||||
|
(media) =>
|
||||||
|
currentMediaType === 'all' || media.mediaType === currentMediaType
|
||||||
|
);
|
||||||
|
const grouped = groupBy(filtered, 'id');
|
||||||
|
|
||||||
const reduced = Object.values(grouped).map((objs) => ({
|
const reduced = Object.values(grouped).map((objs) => ({
|
||||||
...objs[0],
|
...objs[0],
|
||||||
@@ -72,7 +84,7 @@ const PersonDetails = () => {
|
|||||||
}
|
}
|
||||||
return 1;
|
return 1;
|
||||||
});
|
});
|
||||||
}, [combinedCredits]);
|
}, [combinedCredits, currentMediaType]);
|
||||||
|
|
||||||
if (!data && !error) {
|
if (!data && !error) {
|
||||||
return <LoadingSpinner />;
|
return <LoadingSpinner />;
|
||||||
@@ -122,6 +134,29 @@ const PersonDetails = () => {
|
|||||||
|
|
||||||
const isLoading = !combinedCredits && !errorCombinedCredits;
|
const isLoading = !combinedCredits && !errorCombinedCredits;
|
||||||
|
|
||||||
|
const mediaTypePicker = (
|
||||||
|
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||||
|
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
|
||||||
|
<CircleStackIcon className="h-6 w-6" />
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
id="mediaType"
|
||||||
|
name="mediaType"
|
||||||
|
onChange={(e) => {
|
||||||
|
setCurrentMediaType(e.target.value as MediaType);
|
||||||
|
}}
|
||||||
|
value={currentMediaType}
|
||||||
|
className="rounded-r-only"
|
||||||
|
>
|
||||||
|
<option value="all">{intl.formatMessage(globalMessages.all)}</option>
|
||||||
|
<option value="movie">
|
||||||
|
{intl.formatMessage(globalMessages.movies)}
|
||||||
|
</option>
|
||||||
|
<option value="tv">{intl.formatMessage(globalMessages.tvshows)}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const cast = (sortedCast ?? []).length > 0 && (
|
const cast = (sortedCast ?? []).length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="slider-header">
|
<div className="slider-header">
|
||||||
@@ -235,8 +270,13 @@ const PersonDetails = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-center text-gray-300 lg:text-left">
|
<div className="w-full text-center text-gray-300 lg:text-left">
|
||||||
<h1 className="text-3xl text-white lg:text-4xl">{data.name}</h1>
|
<div className="flex w-full items-center justify-center lg:justify-between">
|
||||||
|
<h1 className="text-3xl text-white lg:text-4xl">{data.name}</h1>
|
||||||
|
<div className="hidden flex-shrink-0 lg:block">
|
||||||
|
{mediaTypePicker}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="mt-1 mb-2 space-y-1 text-xs text-white sm:text-sm lg:text-base">
|
<div className="mt-1 mb-2 space-y-1 text-xs text-white sm:text-sm lg:text-base">
|
||||||
<div>{personAttributes.join(' | ')}</div>
|
<div>{personAttributes.join(' | ')}</div>
|
||||||
{(data.alsoKnownAs ?? []).length > 0 && (
|
{(data.alsoKnownAs ?? []).length > 0 && (
|
||||||
@@ -274,6 +314,7 @@ const PersonDetails = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="lg:hidden">{mediaTypePicker}</div>
|
||||||
{data.knownForDepartment === 'Acting' ? [cast, crew] : [crew, cast]}
|
{data.knownForDepartment === 'Acting' ? [cast, crew] : [crew, cast]}
|
||||||
{isLoading && <LoadingSpinner />}
|
{isLoading && <LoadingSpinner />}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Badge from '@app/components/Common/Badge';
|
import Badge from '@app/components/Common/Badge';
|
||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
|
import CachedImage from '@app/components/Common/CachedImage';
|
||||||
import Tooltip from '@app/components/Common/Tooltip';
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
import RequestModal from '@app/components/RequestModal';
|
import RequestModal from '@app/components/RequestModal';
|
||||||
import useRequestOverride from '@app/hooks/useRequestOverride';
|
import useRequestOverride from '@app/hooks/useRequestOverride';
|
||||||
@@ -95,36 +96,58 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5">
|
<div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5">
|
||||||
<div className="white mb-1 flex flex-nowrap">
|
<div className="white mb-1 flex flex-nowrap">
|
||||||
<Tooltip content={intl.formatMessage(messages.requestedby)}>
|
<span className="flex w-40 items-center truncate md:w-auto">
|
||||||
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
|
<Tooltip content={intl.formatMessage(messages.requestedby)}>
|
||||||
</Tooltip>
|
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
|
||||||
<span className="w-40 truncate md:w-auto">
|
</Tooltip>
|
||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
request.requestedBy.id === user?.id
|
request.requestedBy.id === user?.id
|
||||||
? '/profile'
|
? '/profile'
|
||||||
: `/users/${request.requestedBy.id}`
|
: `/users/${request.requestedBy.id}`
|
||||||
}
|
}
|
||||||
className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
|
className="flex items-center font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
|
||||||
>
|
>
|
||||||
|
<span className="avatar-sm">
|
||||||
|
<CachedImage
|
||||||
|
type="avatar"
|
||||||
|
src={request.requestedBy.avatar}
|
||||||
|
alt=""
|
||||||
|
className="avatar-sm object-cover"
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
{request.requestedBy.displayName}
|
{request.requestedBy.displayName}
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{request.modifiedBy && (
|
{request.modifiedBy && (
|
||||||
<div className="flex flex-nowrap">
|
<div className="flex flex-nowrap">
|
||||||
<Tooltip content={intl.formatMessage(messages.lastmodifiedby)}>
|
<span className="flex w-40 items-center truncate md:w-auto">
|
||||||
<EyeIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
|
<Tooltip
|
||||||
</Tooltip>
|
content={intl.formatMessage(messages.lastmodifiedby)}
|
||||||
<span className="w-40 truncate md:w-auto">
|
>
|
||||||
|
<EyeIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
|
||||||
|
</Tooltip>
|
||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
request.modifiedBy.id === user?.id
|
request.modifiedBy.id === user?.id
|
||||||
? '/profile'
|
? '/profile'
|
||||||
: `/users/${request.modifiedBy.id}`
|
: `/users/${request.modifiedBy.id}`
|
||||||
}
|
}
|
||||||
className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
|
className="flex items-center font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
|
||||||
>
|
>
|
||||||
|
<span className="avatar-sm">
|
||||||
|
<CachedImage
|
||||||
|
type="avatar"
|
||||||
|
src={request.modifiedBy.avatar}
|
||||||
|
alt=""
|
||||||
|
className="avatar-sm object-cover"
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
{request.modifiedBy.displayName}
|
{request.modifiedBy.displayName}
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
@@ -206,6 +229,11 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
|
|||||||
{intl.formatMessage(globalMessages.failed)}
|
{intl.formatMessage(globalMessages.failed)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{request.status === MediaRequestStatus.COMPLETED && (
|
||||||
|
<Badge badgeType="success">
|
||||||
|
{intl.formatMessage(globalMessages.completed)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-center text-sm leading-5 sm:mt-0">
|
<div className="mt-2 flex items-center text-sm leading-5 sm:mt-0">
|
||||||
|
|||||||
@@ -268,7 +268,9 @@ const RequestButton = ({
|
|||||||
|
|
||||||
// Standard request button
|
// Standard request button
|
||||||
if (
|
if (
|
||||||
(!media || media.status === MediaStatus.UNKNOWN) &&
|
(!media ||
|
||||||
|
media.status === MediaStatus.UNKNOWN ||
|
||||||
|
(media.status === MediaStatus.DELETED && !activeRequest)) &&
|
||||||
hasPermission(
|
hasPermission(
|
||||||
[
|
[
|
||||||
Permission.REQUEST,
|
Permission.REQUEST,
|
||||||
@@ -295,7 +297,6 @@ const RequestButton = ({
|
|||||||
type: 'or',
|
type: 'or',
|
||||||
}) &&
|
}) &&
|
||||||
media &&
|
media &&
|
||||||
media.status !== MediaStatus.AVAILABLE &&
|
|
||||||
media.status !== MediaStatus.BLACKLISTED &&
|
media.status !== MediaStatus.BLACKLISTED &&
|
||||||
!isShowComplete
|
!isShowComplete
|
||||||
) {
|
) {
|
||||||
@@ -312,7 +313,9 @@ const RequestButton = ({
|
|||||||
|
|
||||||
// 4K request button
|
// 4K request button
|
||||||
if (
|
if (
|
||||||
(!media || media.status4k === MediaStatus.UNKNOWN) &&
|
(!media ||
|
||||||
|
media.status4k === MediaStatus.UNKNOWN ||
|
||||||
|
(media.status4k === MediaStatus.DELETED && !active4kRequest)) &&
|
||||||
hasPermission(
|
hasPermission(
|
||||||
[
|
[
|
||||||
Permission.REQUEST_4K,
|
Permission.REQUEST_4K,
|
||||||
@@ -341,8 +344,7 @@ const RequestButton = ({
|
|||||||
type: 'or',
|
type: 'or',
|
||||||
}) &&
|
}) &&
|
||||||
media &&
|
media &&
|
||||||
media.status4k !== MediaStatus.AVAILABLE &&
|
media.status4k !== MediaStatus.BLACKLISTED &&
|
||||||
media.status !== MediaStatus.BLACKLISTED &&
|
|
||||||
!is4kShowComplete &&
|
!is4kShowComplete &&
|
||||||
settings.currentSettings.series4kEnabled
|
settings.currentSettings.series4kEnabled
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
TrashIcon,
|
TrashIcon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
} from '@heroicons/react/24/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
import { MediaRequestStatus } from '@server/constants/media';
|
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
||||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
@@ -440,6 +440,15 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
>
|
>
|
||||||
{intl.formatMessage(globalMessages.failed)}
|
{intl.formatMessage(globalMessages.failed)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
) : requestData.status === MediaRequestStatus.PENDING &&
|
||||||
|
requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
|
||||||
|
MediaStatus.DELETED ? (
|
||||||
|
<Badge
|
||||||
|
badgeType="warning"
|
||||||
|
href={`/${requestData.type}/${requestData.media.tmdbId}?manage=1`}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(globalMessages.pending)}
|
||||||
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
status={
|
status={
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
TrashIcon,
|
TrashIcon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
} from '@heroicons/react/24/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
import { MediaRequestStatus } from '@server/constants/media';
|
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
||||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||||
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
|
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
|
||||||
@@ -509,6 +509,15 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
>
|
>
|
||||||
{intl.formatMessage(globalMessages.failed)}
|
{intl.formatMessage(globalMessages.failed)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
) : requestData.status === MediaRequestStatus.PENDING &&
|
||||||
|
requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
|
||||||
|
MediaStatus.DELETED ? (
|
||||||
|
<Badge
|
||||||
|
badgeType="warning"
|
||||||
|
href={`/${requestData.type}/${requestData.media.tmdbId}?manage=1`}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(globalMessages.pending)}
|
||||||
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
status={
|
status={
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Bars3BottomLeftIcon,
|
Bars3BottomLeftIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
|
CircleStackIcon,
|
||||||
FunnelIcon,
|
FunnelIcon,
|
||||||
} from '@heroicons/react/24/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
|
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
|
||||||
@@ -39,12 +40,16 @@ enum Filter {
|
|||||||
AVAILABLE = 'available',
|
AVAILABLE = 'available',
|
||||||
UNAVAILABLE = 'unavailable',
|
UNAVAILABLE = 'unavailable',
|
||||||
FAILED = 'failed',
|
FAILED = 'failed',
|
||||||
|
DELETED = 'deleted',
|
||||||
|
COMPLETED = 'completed',
|
||||||
}
|
}
|
||||||
|
|
||||||
type Sort = 'added' | 'modified';
|
type Sort = 'added' | 'modified';
|
||||||
|
|
||||||
type SortDirection = 'asc' | 'desc';
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
type MediaType = 'all' | 'movie' | 'tv';
|
||||||
|
|
||||||
const RequestList = () => {
|
const RequestList = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -54,6 +59,7 @@ const RequestList = () => {
|
|||||||
const { user: currentUser } = useUser();
|
const { user: currentUser } = useUser();
|
||||||
const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.PENDING);
|
const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.PENDING);
|
||||||
const [currentSort, setCurrentSort] = useState<Sort>('added');
|
const [currentSort, setCurrentSort] = useState<Sort>('added');
|
||||||
|
const [currentMediaType, setCurrentMediaType] = useState<string>('all');
|
||||||
const [currentSortDirection, setCurrentSortDirection] =
|
const [currentSortDirection, setCurrentSortDirection] =
|
||||||
useState<SortDirection>('desc');
|
useState<SortDirection>('desc');
|
||||||
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
|
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
|
||||||
@@ -69,7 +75,7 @@ const RequestList = () => {
|
|||||||
} = useSWR<RequestResultsResponse>(
|
} = useSWR<RequestResultsResponse>(
|
||||||
`/api/v1/request?take=${currentPageSize}&skip=${
|
`/api/v1/request?take=${currentPageSize}&skip=${
|
||||||
pageIndex * currentPageSize
|
pageIndex * currentPageSize
|
||||||
}&filter=${currentFilter}&sort=${currentSort}&sortDirection=${currentSortDirection}${
|
}&filter=${currentFilter}&mediaType=${currentMediaType}&sort=${currentSort}&sortDirection=${currentSortDirection}${
|
||||||
router.pathname.startsWith('/profile')
|
router.pathname.startsWith('/profile')
|
||||||
? `&requestedBy=${currentUser?.id}`
|
? `&requestedBy=${currentUser?.id}`
|
||||||
: router.query.userId
|
: router.query.userId
|
||||||
@@ -105,12 +111,19 @@ const RequestList = () => {
|
|||||||
'rl-filter-settings',
|
'rl-filter-settings',
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
currentFilter,
|
currentFilter,
|
||||||
|
currentMediaType,
|
||||||
currentSort,
|
currentSort,
|
||||||
currentSortDirection,
|
currentSortDirection,
|
||||||
currentPageSize,
|
currentPageSize,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}, [currentFilter, currentSort, currentSortDirection, currentPageSize]);
|
}, [
|
||||||
|
currentFilter,
|
||||||
|
currentMediaType,
|
||||||
|
currentSort,
|
||||||
|
currentSortDirection,
|
||||||
|
currentPageSize,
|
||||||
|
]);
|
||||||
|
|
||||||
if (!data && !error) {
|
if (!data && !error) {
|
||||||
return <LoadingSpinner />;
|
return <LoadingSpinner />;
|
||||||
@@ -150,6 +163,36 @@ const RequestList = () => {
|
|||||||
{intl.formatMessage(messages.requests)}
|
{intl.formatMessage(messages.requests)}
|
||||||
</Header>
|
</Header>
|
||||||
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
||||||
|
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||||
|
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
|
||||||
|
<CircleStackIcon className="h-6 w-6" />
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
id="mediaType"
|
||||||
|
name="mediaType"
|
||||||
|
onChange={(e) => {
|
||||||
|
setCurrentMediaType(e.target.value as MediaType);
|
||||||
|
router.push({
|
||||||
|
pathname: router.pathname,
|
||||||
|
query: router.query.userId
|
||||||
|
? { userId: router.query.userId }
|
||||||
|
: {},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
value={currentMediaType}
|
||||||
|
className="rounded-r-only"
|
||||||
|
>
|
||||||
|
<option value="all">
|
||||||
|
{intl.formatMessage(globalMessages.all)}
|
||||||
|
</option>
|
||||||
|
<option value="movie">
|
||||||
|
{intl.formatMessage(globalMessages.movies)}
|
||||||
|
</option>
|
||||||
|
<option value="tv">
|
||||||
|
{intl.formatMessage(globalMessages.tvshows)}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
|
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
|
||||||
<FunnelIcon className="h-6 w-6" />
|
<FunnelIcon className="h-6 w-6" />
|
||||||
@@ -178,6 +221,9 @@ const RequestList = () => {
|
|||||||
<option value="approved">
|
<option value="approved">
|
||||||
{intl.formatMessage(globalMessages.approved)}
|
{intl.formatMessage(globalMessages.approved)}
|
||||||
</option>
|
</option>
|
||||||
|
<option value="completed">
|
||||||
|
{intl.formatMessage(globalMessages.completed)}
|
||||||
|
</option>
|
||||||
<option value="processing">
|
<option value="processing">
|
||||||
{intl.formatMessage(globalMessages.processing)}
|
{intl.formatMessage(globalMessages.processing)}
|
||||||
</option>
|
</option>
|
||||||
@@ -190,6 +236,9 @@ const RequestList = () => {
|
|||||||
<option value="unavailable">
|
<option value="unavailable">
|
||||||
{intl.formatMessage(globalMessages.unavailable)}
|
{intl.formatMessage(globalMessages.unavailable)}
|
||||||
</option>
|
</option>
|
||||||
|
<option value="deleted">
|
||||||
|
{intl.formatMessage(globalMessages.deleted)}
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
|
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
|
||||||
@@ -255,11 +304,15 @@ const RequestList = () => {
|
|||||||
<span className="text-2xl text-gray-400">
|
<span className="text-2xl text-gray-400">
|
||||||
{intl.formatMessage(globalMessages.noresults)}
|
{intl.formatMessage(globalMessages.noresults)}
|
||||||
</span>
|
</span>
|
||||||
{currentFilter !== Filter.ALL && (
|
{(currentFilter !== Filter.ALL ||
|
||||||
|
currentMediaType !== Filter.ALL) && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Button
|
<Button
|
||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
onClick={() => setCurrentFilter(Filter.ALL)}
|
onClick={() => {
|
||||||
|
setCurrentFilter(Filter.ALL);
|
||||||
|
setCurrentMediaType(Filter.ALL);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.showallrequests)}
|
{intl.formatMessage(messages.showallrequests)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ const CollectionRequestModal = ({
|
|||||||
.filter(
|
.filter(
|
||||||
(request) =>
|
(request) =>
|
||||||
request.is4k === is4k &&
|
request.is4k === is4k &&
|
||||||
request.status !== MediaRequestStatus.DECLINED
|
request.status !== MediaRequestStatus.DECLINED &&
|
||||||
|
request.status !== MediaRequestStatus.COMPLETED
|
||||||
)
|
)
|
||||||
.map((part) => part.id),
|
.map((part) => part.id),
|
||||||
];
|
];
|
||||||
@@ -170,7 +171,9 @@ const CollectionRequestModal = ({
|
|||||||
|
|
||||||
return (part?.mediaInfo?.requests ?? []).find(
|
return (part?.mediaInfo?.requests ?? []).find(
|
||||||
(request) =>
|
(request) =>
|
||||||
request.is4k === is4k && request.status !== MediaRequestStatus.DECLINED
|
request.is4k === is4k &&
|
||||||
|
request.status !== MediaRequestStatus.DECLINED &&
|
||||||
|
request.status !== MediaRequestStatus.COMPLETED
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -368,7 +371,9 @@ const CollectionRequestModal = ({
|
|||||||
const partMedia =
|
const partMedia =
|
||||||
part.mediaInfo &&
|
part.mediaInfo &&
|
||||||
part.mediaInfo[is4k ? 'status4k' : 'status'] !==
|
part.mediaInfo[is4k ? 'status4k' : 'status'] !==
|
||||||
MediaStatus.UNKNOWN
|
MediaStatus.UNKNOWN &&
|
||||||
|
part.mediaInfo[is4k ? 'status4k' : 'status'] !==
|
||||||
|
MediaStatus.DELETED
|
||||||
? part.mediaInfo
|
? part.mediaInfo
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import Alert from '@app/components/Common/Alert';
|
import Alert from '@app/components/Common/Alert';
|
||||||
|
import CachedImage from '@app/components/Common/CachedImage';
|
||||||
import Modal from '@app/components/Common/Modal';
|
import Modal from '@app/components/Common/Modal';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type { SonarrSeries } from '@server/api/servarr/sonarr';
|
import type { SonarrSeries } from '@server/api/servarr/sonarr';
|
||||||
import Image from 'next/image';
|
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
@@ -89,7 +89,8 @@ const SearchByNameModal = ({
|
|||||||
} `}
|
} `}
|
||||||
>
|
>
|
||||||
<div className="relative flex w-24 flex-none items-center space-x-4 self-stretch">
|
<div className="relative flex w-24 flex-none items-center space-x-4 self-stretch">
|
||||||
<Image
|
<CachedImage
|
||||||
|
type="tvdb"
|
||||||
src={
|
src={
|
||||||
item.remotePoster ??
|
item.remotePoster ??
|
||||||
'/images/jellyseerr_poster_not_found.png'
|
'/images/jellyseerr_poster_not_found.png'
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ const TvRequestModal = ({
|
|||||||
languageProfileId: requestOverrides?.language,
|
languageProfileId: requestOverrides?.language,
|
||||||
userId: requestOverrides?.user?.id,
|
userId: requestOverrides?.user?.id,
|
||||||
tags: requestOverrides?.tags,
|
tags: requestOverrides?.tags,
|
||||||
seasons: selectedSeasons,
|
seasons: selectedSeasons.sort((a, b) => a - b),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (alsoApproveRequest) {
|
if (alsoApproveRequest) {
|
||||||
@@ -202,7 +202,8 @@ const TvRequestModal = ({
|
|||||||
seasons: settings.currentSettings.partialRequestsEnabled
|
seasons: settings.currentSettings.partialRequestsEnabled
|
||||||
? selectedSeasons.sort((a, b) => a - b)
|
? selectedSeasons.sort((a, b) => a - b)
|
||||||
: getAllSeasons().filter(
|
: getAllSeasons().filter(
|
||||||
(season) => !getAllRequestedSeasons().includes(season)
|
(season) =>
|
||||||
|
!getAllRequestedSeasons().includes(season) && season !== 0
|
||||||
),
|
),
|
||||||
...overrideParams,
|
...overrideParams,
|
||||||
});
|
});
|
||||||
@@ -249,7 +250,8 @@ const TvRequestModal = ({
|
|||||||
.filter(
|
.filter(
|
||||||
(request) =>
|
(request) =>
|
||||||
request.is4k === is4k &&
|
request.is4k === is4k &&
|
||||||
request.status !== MediaRequestStatus.DECLINED
|
request.status !== MediaRequestStatus.DECLINED &&
|
||||||
|
request.status !== MediaRequestStatus.COMPLETED
|
||||||
)
|
)
|
||||||
.reduce((requestedSeasons, request) => {
|
.reduce((requestedSeasons, request) => {
|
||||||
return [
|
return [
|
||||||
@@ -301,8 +303,10 @@ const TvRequestModal = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const unrequestedSeasons = getAllSeasons().filter(
|
const unrequestedSeasons = getAllSeasons().filter((season) =>
|
||||||
(season) => !getAllRequestedSeasons().includes(season)
|
!settings.currentSettings.partialRequestsEnabled
|
||||||
|
? !getAllRequestedSeasons().includes(season) && season !== 0
|
||||||
|
: !getAllRequestedSeasons().includes(season)
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleAllSeasons = (): void => {
|
const toggleAllSeasons = (): void => {
|
||||||
@@ -314,12 +318,16 @@ const TvRequestModal = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const standardUnrequestedSeasons = unrequestedSeasons.filter(
|
||||||
|
(seasonNumber) => seasonNumber !== 0
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
data &&
|
data &&
|
||||||
selectedSeasons.length >= 0 &&
|
selectedSeasons.length >= 0 &&
|
||||||
selectedSeasons.length < unrequestedSeasons.length
|
selectedSeasons.length < standardUnrequestedSeasons.length
|
||||||
) {
|
) {
|
||||||
setSelectedSeasons(unrequestedSeasons);
|
setSelectedSeasons(standardUnrequestedSeasons);
|
||||||
} else {
|
} else {
|
||||||
setSelectedSeasons([]);
|
setSelectedSeasons([]);
|
||||||
}
|
}
|
||||||
@@ -330,9 +338,9 @@ const TvRequestModal = ({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
selectedSeasons.length ===
|
selectedSeasons.filter((season) => season !== 0).length ===
|
||||||
getAllSeasons().filter(
|
getAllSeasons().filter(
|
||||||
(season) => !getAllRequestedSeasons().includes(season)
|
(season) => !getAllRequestedSeasons().includes(season) && season !== 0
|
||||||
).length
|
).length
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -347,7 +355,8 @@ const TvRequestModal = ({
|
|||||||
(data.mediaInfo.requests || []).filter(
|
(data.mediaInfo.requests || []).filter(
|
||||||
(request) =>
|
(request) =>
|
||||||
request.is4k === is4k &&
|
request.is4k === is4k &&
|
||||||
request.status !== MediaRequestStatus.DECLINED
|
request.status !== MediaRequestStatus.DECLINED &&
|
||||||
|
request.status !== MediaRequestStatus.COMPLETED
|
||||||
).length > 0
|
).length > 0
|
||||||
) {
|
) {
|
||||||
data.mediaInfo.requests
|
data.mediaInfo.requests
|
||||||
@@ -355,7 +364,9 @@ const TvRequestModal = ({
|
|||||||
.forEach((request) => {
|
.forEach((request) => {
|
||||||
if (!seasonRequest) {
|
if (!seasonRequest) {
|
||||||
seasonRequest = request.seasons.find(
|
seasonRequest = request.seasons.find(
|
||||||
(season) => season.seasonNumber === seasonNumber
|
(season) =>
|
||||||
|
season.seasonNumber === seasonNumber &&
|
||||||
|
season.status !== MediaRequestStatus.COMPLETED
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -567,7 +578,11 @@ const TvRequestModal = ({
|
|||||||
(season) =>
|
(season) =>
|
||||||
(!settings.currentSettings.enableSpecialEpisodes
|
(!settings.currentSettings.enableSpecialEpisodes
|
||||||
? season.seasonNumber !== 0
|
? season.seasonNumber !== 0
|
||||||
: true) && season.episodeCount !== 0
|
: true) &&
|
||||||
|
(!settings.currentSettings.partialRequestsEnabled
|
||||||
|
? season.episodeCount !== 0 &&
|
||||||
|
season.seasonNumber !== 0
|
||||||
|
: season.episodeCount !== 0)
|
||||||
)
|
)
|
||||||
.map((season) => {
|
.map((season) => {
|
||||||
const seasonRequest = getSeasonRequest(
|
const seasonRequest = getSeasonRequest(
|
||||||
@@ -577,7 +592,9 @@ const TvRequestModal = ({
|
|||||||
(sn) =>
|
(sn) =>
|
||||||
sn.seasonNumber === season.seasonNumber &&
|
sn.seasonNumber === season.seasonNumber &&
|
||||||
sn[is4k ? 'status4k' : 'status'] !==
|
sn[is4k ? 'status4k' : 'status'] !==
|
||||||
MediaStatus.UNKNOWN
|
MediaStatus.UNKNOWN &&
|
||||||
|
sn[is4k ? 'status4k' : 'status'] !==
|
||||||
|
MediaStatus.DELETED
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<tr key={`season-${season.id}`}>
|
<tr key={`season-${season.id}`}>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const Search = () => {
|
|||||||
{
|
{
|
||||||
query: router.query.query,
|
query: router.query.query,
|
||||||
},
|
},
|
||||||
{ hideAvailable: false }
|
{ hideAvailable: false, hideBlacklisted: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
333
src/components/Selector/CertificationSelector.tsx
Normal file
333
src/components/Selector/CertificationSelector.tsx
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import type { Region } from '@server/lib/settings';
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import AsyncSelect from 'react-select/async';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
interface Certification {
|
||||||
|
certification: string;
|
||||||
|
meaning?: string;
|
||||||
|
order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CertificationResponse {
|
||||||
|
certifications: {
|
||||||
|
[country: string]: Certification[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CertificationOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
certification?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CertificationSelectorProps {
|
||||||
|
type: string;
|
||||||
|
certificationCountry?: string;
|
||||||
|
certification?: string;
|
||||||
|
certificationGte?: string;
|
||||||
|
certificationLte?: string;
|
||||||
|
onChange: (params: {
|
||||||
|
certificationCountry?: string;
|
||||||
|
certification?: string;
|
||||||
|
certificationGte?: string;
|
||||||
|
certificationLte?: string;
|
||||||
|
}) => void;
|
||||||
|
showRange?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = defineMessages('components.Selector.CertificationSelector', {
|
||||||
|
selectCountry: 'Select a country',
|
||||||
|
selectCertification: 'Select a certification',
|
||||||
|
minRating: 'Minimum rating',
|
||||||
|
maxRating: 'Maximum rating',
|
||||||
|
noOptions: 'No options available',
|
||||||
|
starttyping: 'Starting typing to search.',
|
||||||
|
errorLoading: 'Failed to load certifications',
|
||||||
|
});
|
||||||
|
|
||||||
|
const CertificationSelector: React.FC<CertificationSelectorProps> = ({
|
||||||
|
type,
|
||||||
|
certificationCountry,
|
||||||
|
certification,
|
||||||
|
certificationGte,
|
||||||
|
certificationLte,
|
||||||
|
showRange = false,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const [selectedCountry, setSelectedCountry] =
|
||||||
|
useState<CertificationOption | null>(
|
||||||
|
certificationCountry
|
||||||
|
? { value: certificationCountry, label: certificationCountry }
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
const [selectedCertification, setSelectedCertification] =
|
||||||
|
useState<CertificationOption | null>(null);
|
||||||
|
const [selectedCertificationGte, setSelectedCertificationGte] =
|
||||||
|
useState<CertificationOption | null>(null);
|
||||||
|
const [selectedCertificationLte, setSelectedCertificationLte] =
|
||||||
|
useState<CertificationOption | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: certificationData,
|
||||||
|
error: certificationError,
|
||||||
|
isLoading: certificationLoading,
|
||||||
|
} = useSWR<CertificationResponse>(`/api/v1/certifications/${type}`);
|
||||||
|
|
||||||
|
const { data: regionsData } = useSWR<Region[]>('/api/v1/regions');
|
||||||
|
|
||||||
|
// Get the country name from its code
|
||||||
|
const getCountryName = useCallback(
|
||||||
|
(countryCode: string): string => {
|
||||||
|
const region = regionsData?.find(
|
||||||
|
(region) => region.iso_3166_1 === countryCode
|
||||||
|
);
|
||||||
|
return region?.name || countryCode;
|
||||||
|
},
|
||||||
|
[regionsData]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (certificationCountry && regionsData) {
|
||||||
|
setSelectedCountry({
|
||||||
|
value: certificationCountry,
|
||||||
|
label: getCountryName(certificationCountry),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [certificationCountry, regionsData, getCountryName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!certificationData || !certificationCountry) return;
|
||||||
|
|
||||||
|
const certifications = (
|
||||||
|
certificationData.certifications[certificationCountry] || []
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.order !== undefined && b.order !== undefined) {
|
||||||
|
return a.order - b.order;
|
||||||
|
}
|
||||||
|
return a.certification.localeCompare(b.certification);
|
||||||
|
})
|
||||||
|
.map((cert) => ({
|
||||||
|
value: cert.certification,
|
||||||
|
label: `${cert.certification}${
|
||||||
|
cert.meaning ? ` - ${cert.meaning}` : ''
|
||||||
|
}`,
|
||||||
|
certification: cert.certification,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (certification) {
|
||||||
|
setSelectedCertification(
|
||||||
|
certifications.find((c) => c.value === certification) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (certificationGte) {
|
||||||
|
setSelectedCertificationGte(
|
||||||
|
certifications.find((c) => c.value === certificationGte) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (certificationLte) {
|
||||||
|
setSelectedCertificationLte(
|
||||||
|
certifications.find((c) => c.value === certificationLte) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
certificationData,
|
||||||
|
certificationCountry,
|
||||||
|
certification,
|
||||||
|
certificationGte,
|
||||||
|
certificationLte,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (certificationError) {
|
||||||
|
return (
|
||||||
|
<div className="text-red-500">
|
||||||
|
{intl.formatMessage(messages.errorLoading)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (certificationLoading || !certificationData) {
|
||||||
|
return <SmallLoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadCountryOptions = async (inputValue: string) => {
|
||||||
|
if (!certificationData || !regionsData) return [];
|
||||||
|
|
||||||
|
return Object.keys(certificationData.certifications)
|
||||||
|
.filter(
|
||||||
|
(code) =>
|
||||||
|
certificationData.certifications[code] &&
|
||||||
|
certificationData.certifications[code].length > 0 &&
|
||||||
|
(code.toLowerCase().includes(inputValue.toLowerCase()) ||
|
||||||
|
getCountryName(code)
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(inputValue.toLowerCase()))
|
||||||
|
)
|
||||||
|
.sort((a, b) => getCountryName(a).localeCompare(getCountryName(b)))
|
||||||
|
.map((code) => ({
|
||||||
|
value: code,
|
||||||
|
label: getCountryName(code),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCertificationOptions = async (inputValue: string) => {
|
||||||
|
if (!certificationData || !certificationCountry) return [];
|
||||||
|
|
||||||
|
return (certificationData.certifications[certificationCountry] || [])
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.order !== undefined && b.order !== undefined) {
|
||||||
|
return a.order - b.order;
|
||||||
|
}
|
||||||
|
return a.certification.localeCompare(b.certification);
|
||||||
|
})
|
||||||
|
.map((cert) => ({
|
||||||
|
value: cert.certification,
|
||||||
|
label: `${cert.certification}${
|
||||||
|
cert.meaning ? ` - ${cert.meaning}` : ''
|
||||||
|
}`,
|
||||||
|
certification: cert.certification,
|
||||||
|
}))
|
||||||
|
.filter((cert) =>
|
||||||
|
cert.label.toLowerCase().includes(inputValue.toLowerCase())
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCountryChange = (option: CertificationOption | null) => {
|
||||||
|
setSelectedCountry(option);
|
||||||
|
setSelectedCertification(null);
|
||||||
|
setSelectedCertificationGte(null);
|
||||||
|
setSelectedCertificationLte(null);
|
||||||
|
|
||||||
|
onChange({
|
||||||
|
certificationCountry: option?.value,
|
||||||
|
certification: undefined,
|
||||||
|
certificationGte: undefined,
|
||||||
|
certificationLte: undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCertificationChange = (option: CertificationOption | null) => {
|
||||||
|
setSelectedCertification(option);
|
||||||
|
|
||||||
|
onChange({
|
||||||
|
certificationCountry,
|
||||||
|
certification: option?.value,
|
||||||
|
certificationGte: undefined,
|
||||||
|
certificationLte: undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMinCertificationChange = (option: CertificationOption | null) => {
|
||||||
|
setSelectedCertificationGte(option);
|
||||||
|
|
||||||
|
onChange({
|
||||||
|
certificationCountry,
|
||||||
|
certification: undefined,
|
||||||
|
certificationGte: option?.value,
|
||||||
|
certificationLte: certificationLte,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMaxCertificationChange = (option: CertificationOption | null) => {
|
||||||
|
setSelectedCertificationLte(option);
|
||||||
|
|
||||||
|
onChange({
|
||||||
|
certificationCountry,
|
||||||
|
certification: undefined,
|
||||||
|
certificationGte: certificationGte,
|
||||||
|
certificationLte: option?.value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCertificationLabel = (
|
||||||
|
option: CertificationOption,
|
||||||
|
{ context }: { context: string }
|
||||||
|
) => {
|
||||||
|
if (context === 'value') {
|
||||||
|
return option.certification || option.value;
|
||||||
|
}
|
||||||
|
// Show the full label with description in the menu
|
||||||
|
return option.label;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<AsyncSelect
|
||||||
|
className="react-select-container"
|
||||||
|
classNamePrefix="react-select"
|
||||||
|
cacheOptions
|
||||||
|
defaultOptions
|
||||||
|
loadOptions={loadCountryOptions}
|
||||||
|
value={selectedCountry}
|
||||||
|
onChange={handleCountryChange}
|
||||||
|
placeholder={intl.formatMessage(messages.selectCountry)}
|
||||||
|
isClearable
|
||||||
|
noOptionsMessage={({ inputValue }) =>
|
||||||
|
inputValue === ''
|
||||||
|
? intl.formatMessage(messages.starttyping)
|
||||||
|
: intl.formatMessage(messages.noOptions)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{certificationCountry && !showRange && (
|
||||||
|
<AsyncSelect
|
||||||
|
className="react-select-container"
|
||||||
|
classNamePrefix="react-select"
|
||||||
|
cacheOptions
|
||||||
|
defaultOptions
|
||||||
|
loadOptions={loadCertificationOptions}
|
||||||
|
value={selectedCertification}
|
||||||
|
onChange={handleCertificationChange}
|
||||||
|
placeholder={intl.formatMessage(messages.selectCertification)}
|
||||||
|
formatOptionLabel={formatCertificationLabel}
|
||||||
|
isClearable
|
||||||
|
noOptionsMessage={() => intl.formatMessage(messages.noOptions)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{certificationCountry && showRange && (
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<AsyncSelect
|
||||||
|
className="react-select-container"
|
||||||
|
classNamePrefix="react-select"
|
||||||
|
cacheOptions
|
||||||
|
defaultOptions
|
||||||
|
loadOptions={loadCertificationOptions}
|
||||||
|
value={selectedCertificationGte}
|
||||||
|
onChange={handleMinCertificationChange}
|
||||||
|
placeholder={intl.formatMessage(messages.minRating)}
|
||||||
|
formatOptionLabel={formatCertificationLabel}
|
||||||
|
isClearable
|
||||||
|
noOptionsMessage={() => intl.formatMessage(messages.noOptions)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<AsyncSelect
|
||||||
|
className="react-select-container"
|
||||||
|
classNamePrefix="react-select"
|
||||||
|
cacheOptions
|
||||||
|
defaultOptions
|
||||||
|
loadOptions={loadCertificationOptions}
|
||||||
|
value={selectedCertificationLte}
|
||||||
|
onChange={handleMaxCertificationChange}
|
||||||
|
placeholder={intl.formatMessage(messages.maxRating)}
|
||||||
|
formatOptionLabel={formatCertificationLabel}
|
||||||
|
isClearable
|
||||||
|
noOptionsMessage={() => intl.formatMessage(messages.noOptions)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CertificationSelector;
|
||||||
87
src/components/Selector/USCertificationSelector.tsx
Normal file
87
src/components/Selector/USCertificationSelector.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface USCertificationSelectorProps {
|
||||||
|
type: string;
|
||||||
|
certification?: string;
|
||||||
|
onChange: (params: {
|
||||||
|
certificationCountry?: string;
|
||||||
|
certification?: string;
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const US_MOVIE_CERTIFICATIONS = ['NR', 'G', 'PG', 'PG-13', 'R', 'NC-17'];
|
||||||
|
const US_TV_CERTIFICATIONS = [
|
||||||
|
'NR',
|
||||||
|
'TV-Y',
|
||||||
|
'TV-Y7',
|
||||||
|
'TV-G',
|
||||||
|
'TV-PG',
|
||||||
|
'TV-14',
|
||||||
|
'TV-MA',
|
||||||
|
];
|
||||||
|
|
||||||
|
const USCertificationSelector: React.FC<USCertificationSelectorProps> = ({
|
||||||
|
type,
|
||||||
|
certification,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
const [selectedRatings, setSelectedRatings] = useState<string[]>(() =>
|
||||||
|
certification ? certification.split('|') : []
|
||||||
|
);
|
||||||
|
|
||||||
|
const certifications =
|
||||||
|
type === 'movie' ? US_MOVIE_CERTIFICATIONS : US_TV_CERTIFICATIONS;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (certification) {
|
||||||
|
setSelectedRatings(certification.split('|'));
|
||||||
|
} else {
|
||||||
|
setSelectedRatings([]);
|
||||||
|
}
|
||||||
|
}, [certification]);
|
||||||
|
|
||||||
|
const toggleRating = (rating: string) => {
|
||||||
|
setSelectedRatings((prevSelected) => {
|
||||||
|
let newSelected;
|
||||||
|
|
||||||
|
if (prevSelected.includes(rating)) {
|
||||||
|
newSelected = prevSelected.filter((r) => r !== rating);
|
||||||
|
} else {
|
||||||
|
newSelected = [...prevSelected, rating];
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCertification =
|
||||||
|
newSelected.length > 0 ? newSelected.join('|') : undefined;
|
||||||
|
|
||||||
|
onChange({
|
||||||
|
certificationCountry: 'US',
|
||||||
|
certification: newCertification,
|
||||||
|
});
|
||||||
|
|
||||||
|
return newSelected;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{certifications.map((rating) => (
|
||||||
|
<button
|
||||||
|
key={rating}
|
||||||
|
onClick={() => toggleRating(rating)}
|
||||||
|
className={`rounded-full px-3 py-1 text-sm font-medium transition-colors ${
|
||||||
|
selectedRatings.includes(rating)
|
||||||
|
? 'bg-indigo-600 text-white hover:bg-indigo-700'
|
||||||
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{rating}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default USCertificationSelector;
|
||||||
@@ -631,3 +631,5 @@ export const UserSelector = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { default as USCertificationSelector } from './USCertificationSelector';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
|||||||
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
|
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { isValidURL } from '@app/utils/urlValidationHelper';
|
||||||
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/solid';
|
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/solid';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
@@ -51,10 +52,10 @@ const NotificationsGotify = () => {
|
|||||||
.required(intl.formatMessage(messages.validationUrlRequired)),
|
.required(intl.formatMessage(messages.validationUrlRequired)),
|
||||||
otherwise: Yup.string().nullable(),
|
otherwise: Yup.string().nullable(),
|
||||||
})
|
})
|
||||||
.matches(
|
.test(
|
||||||
// eslint-disable-next-line no-useless-escape
|
'valid-url',
|
||||||
/^(https?:)?\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i,
|
intl.formatMessage(messages.validationUrlRequired),
|
||||||
intl.formatMessage(messages.validationUrlRequired)
|
isValidURL
|
||||||
)
|
)
|
||||||
.test(
|
.test(
|
||||||
'no-trailing-slash',
|
'no-trailing-slash',
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
import Button from '@app/components/Common/Button';
|
||||||
|
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||||
|
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||||
|
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
|
||||||
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { isValidURL } from '@app/utils/urlValidationHelper';
|
||||||
|
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
|
||||||
|
import type { NotificationAgentNtfy } from '@server/lib/settings';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Field, Form, Formik } from 'formik';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import { useToasts } from 'react-toast-notifications';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
|
const messages = defineMessages(
|
||||||
|
'components.Settings.Notifications.NotificationsNtfy',
|
||||||
|
{
|
||||||
|
agentenabled: 'Enable Agent',
|
||||||
|
url: 'Server root URL',
|
||||||
|
topic: 'Topic',
|
||||||
|
usernamePasswordAuth: 'Username + Password authentication',
|
||||||
|
username: 'Username',
|
||||||
|
password: 'Password',
|
||||||
|
tokenAuth: 'Token authentication',
|
||||||
|
token: 'Token',
|
||||||
|
ntfysettingssaved: 'Ntfy notification settings saved successfully!',
|
||||||
|
ntfysettingsfailed: 'Ntfy notification settings failed to save.',
|
||||||
|
toastNtfyTestSending: 'Sending ntfy test notification…',
|
||||||
|
toastNtfyTestSuccess: 'Ntfy test notification sent!',
|
||||||
|
toastNtfyTestFailed: 'Ntfy test notification failed to send.',
|
||||||
|
validationNtfyUrl: 'You must provide a valid URL',
|
||||||
|
validationNtfyTopic: 'You must provide a topic',
|
||||||
|
validationTypes: 'You must select at least one notification type',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const NotificationsNtfy = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { addToast, removeToast } = useToasts();
|
||||||
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
mutate: revalidate,
|
||||||
|
} = useSWR<NotificationAgentNtfy>('/api/v1/settings/notifications/ntfy');
|
||||||
|
|
||||||
|
const NotificationsNtfySchema = Yup.object().shape({
|
||||||
|
url: Yup.string()
|
||||||
|
.when('enabled', {
|
||||||
|
is: true,
|
||||||
|
then: Yup.string()
|
||||||
|
.nullable()
|
||||||
|
.required(intl.formatMessage(messages.validationNtfyUrl)),
|
||||||
|
otherwise: Yup.string().nullable(),
|
||||||
|
})
|
||||||
|
.test(
|
||||||
|
'valid-url',
|
||||||
|
intl.formatMessage(messages.validationNtfyUrl),
|
||||||
|
isValidURL
|
||||||
|
),
|
||||||
|
topic: Yup.string()
|
||||||
|
.when('enabled', {
|
||||||
|
is: true,
|
||||||
|
then: Yup.string()
|
||||||
|
.nullable()
|
||||||
|
.required(intl.formatMessage(messages.validationNtfyUrl)),
|
||||||
|
otherwise: Yup.string().nullable(),
|
||||||
|
})
|
||||||
|
.defined(intl.formatMessage(messages.validationNtfyTopic)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data && !error) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
enabled: data?.enabled,
|
||||||
|
types: data?.types,
|
||||||
|
url: data?.options.url,
|
||||||
|
topic: data?.options.topic,
|
||||||
|
authMethodUsernamePassword: data?.options.authMethodUsernamePassword,
|
||||||
|
username: data?.options.username,
|
||||||
|
password: data?.options.password,
|
||||||
|
authMethodToken: data?.options.authMethodToken,
|
||||||
|
token: data?.options.token,
|
||||||
|
}}
|
||||||
|
validationSchema={NotificationsNtfySchema}
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
try {
|
||||||
|
await axios.post('/api/v1/settings/notifications/ntfy', {
|
||||||
|
enabled: values.enabled,
|
||||||
|
types: values.types,
|
||||||
|
options: {
|
||||||
|
url: values.url,
|
||||||
|
topic: values.topic,
|
||||||
|
authMethodUsernamePassword: values.authMethodUsernamePassword,
|
||||||
|
username: values.username,
|
||||||
|
password: values.password,
|
||||||
|
authMethodToken: values.authMethodToken,
|
||||||
|
token: values.token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
addToast(intl.formatMessage(messages.ntfysettingssaved), {
|
||||||
|
appearance: 'success',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
addToast(intl.formatMessage(messages.ntfysettingsfailed), {
|
||||||
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
revalidate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({
|
||||||
|
errors,
|
||||||
|
touched,
|
||||||
|
isSubmitting,
|
||||||
|
values,
|
||||||
|
isValid,
|
||||||
|
setFieldValue,
|
||||||
|
setFieldTouched,
|
||||||
|
}) => {
|
||||||
|
const testSettings = async () => {
|
||||||
|
setIsTesting(true);
|
||||||
|
let toastId: string | undefined;
|
||||||
|
try {
|
||||||
|
addToast(
|
||||||
|
intl.formatMessage(messages.toastNtfyTestSending),
|
||||||
|
{
|
||||||
|
autoDismiss: false,
|
||||||
|
appearance: 'info',
|
||||||
|
},
|
||||||
|
(id) => {
|
||||||
|
toastId = id;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await axios.post('/api/v1/settings/notifications/ntfy/test', {
|
||||||
|
enabled: true,
|
||||||
|
types: values.types,
|
||||||
|
options: {
|
||||||
|
url: values.url,
|
||||||
|
topic: values.topic,
|
||||||
|
authMethodUsernamePassword: values.authMethodUsernamePassword,
|
||||||
|
username: values.username,
|
||||||
|
password: values.password,
|
||||||
|
authMethodToken: values.authMethodToken,
|
||||||
|
token: values.token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (toastId) {
|
||||||
|
removeToast(toastId);
|
||||||
|
}
|
||||||
|
addToast(intl.formatMessage(messages.toastNtfyTestSuccess), {
|
||||||
|
autoDismiss: true,
|
||||||
|
appearance: 'success',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (toastId) {
|
||||||
|
removeToast(toastId);
|
||||||
|
}
|
||||||
|
addToast(intl.formatMessage(messages.toastNtfyTestFailed), {
|
||||||
|
autoDismiss: true,
|
||||||
|
appearance: 'error',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form className="section">
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="enabled" className="checkbox-label">
|
||||||
|
{intl.formatMessage(messages.agentenabled)}
|
||||||
|
<span className="label-required">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field type="checkbox" id="enabled" name="enabled" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="url" className="text-label">
|
||||||
|
{intl.formatMessage(messages.url)}
|
||||||
|
<span className="label-required">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field id="url" name="url" type="text" inputMode="url" />
|
||||||
|
</div>
|
||||||
|
{errors.url &&
|
||||||
|
touched.url &&
|
||||||
|
typeof errors.url === 'string' && (
|
||||||
|
<div className="error">{errors.url}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="topic" className="text-label">
|
||||||
|
{intl.formatMessage(messages.topic)}
|
||||||
|
<span className="label-required">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field id="topic" name="topic" type="text" />
|
||||||
|
</div>
|
||||||
|
{errors.topic &&
|
||||||
|
touched.topic &&
|
||||||
|
typeof errors.topic === 'string' && (
|
||||||
|
<div className="error">{errors.topic}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label
|
||||||
|
htmlFor="authMethodUsernamePassword"
|
||||||
|
className="checkbox-label"
|
||||||
|
>
|
||||||
|
<span className="mr-2">
|
||||||
|
{intl.formatMessage(messages.usernamePasswordAuth)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field
|
||||||
|
type="checkbox"
|
||||||
|
id="authMethodUsernamePassword"
|
||||||
|
name="authMethodUsernamePassword"
|
||||||
|
disabled={values.authMethodToken}
|
||||||
|
onChange={() => {
|
||||||
|
setFieldValue(
|
||||||
|
'authMethodUsernamePassword',
|
||||||
|
!values.authMethodUsernamePassword
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{values.authMethodUsernamePassword && (
|
||||||
|
<div className="mr-2 ml-4">
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="username" className="text-label">
|
||||||
|
{intl.formatMessage(messages.username)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field id="username" name="username" type="text" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="password" className="text-label">
|
||||||
|
{intl.formatMessage(messages.password)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<SensitiveInput
|
||||||
|
as="field"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="authMethodToken" className="checkbox-label">
|
||||||
|
<span className="mr-2">
|
||||||
|
{intl.formatMessage(messages.tokenAuth)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field
|
||||||
|
type="checkbox"
|
||||||
|
id="authMethodToken"
|
||||||
|
name="authMethodToken"
|
||||||
|
disabled={values.authMethodUsernamePassword}
|
||||||
|
onChange={() => {
|
||||||
|
setFieldValue('authMethodToken', !values.authMethodToken);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{values.authMethodToken && (
|
||||||
|
<div className="form-row mr-2 ml-4">
|
||||||
|
<label htmlFor="token" className="text-label">
|
||||||
|
{intl.formatMessage(messages.token)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<SensitiveInput as="field" id="token" name="token" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<NotificationTypeSelector
|
||||||
|
currentTypes={values.enabled ? values.types || 0 : 0}
|
||||||
|
onUpdate={(newTypes) => {
|
||||||
|
setFieldValue('types', newTypes);
|
||||||
|
setFieldTouched('types');
|
||||||
|
|
||||||
|
if (newTypes) {
|
||||||
|
setFieldValue('enabled', true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
error={
|
||||||
|
values.enabled && !values.types && touched.types
|
||||||
|
? intl.formatMessage(messages.validationTypes)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="actions">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||||
|
<Button
|
||||||
|
buttonType="warning"
|
||||||
|
disabled={isSubmitting || !isValid || isTesting}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
testSettings();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BeakerIcon />
|
||||||
|
<span>
|
||||||
|
{isTesting
|
||||||
|
? intl.formatMessage(globalMessages.testing)
|
||||||
|
: intl.formatMessage(globalMessages.test)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||||
|
<Button
|
||||||
|
buttonType="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
isSubmitting ||
|
||||||
|
!isValid ||
|
||||||
|
isTesting ||
|
||||||
|
(values.enabled && !values.types)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ArrowDownOnSquareIcon />
|
||||||
|
<span>
|
||||||
|
{isSubmitting
|
||||||
|
? intl.formatMessage(globalMessages.saving)
|
||||||
|
: intl.formatMessage(globalMessages.save)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationsNtfy;
|
||||||
@@ -100,6 +100,7 @@ const NotificationsPushover = () => {
|
|||||||
options: {
|
options: {
|
||||||
accessToken: values.accessToken,
|
accessToken: values.accessToken,
|
||||||
userToken: values.userToken,
|
userToken: values.userToken,
|
||||||
|
sound: values.sound,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
addToast(intl.formatMessage(messages.pushoversettingssaved), {
|
addToast(intl.formatMessage(messages.pushoversettingssaved), {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
|||||||
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
|
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { isValidURL } from '@app/utils/urlValidationHelper';
|
||||||
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
|
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
|
||||||
import {
|
import {
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
@@ -107,10 +108,10 @@ const NotificationsWebhook = () => {
|
|||||||
.required(intl.formatMessage(messages.validationWebhookUrl)),
|
.required(intl.formatMessage(messages.validationWebhookUrl)),
|
||||||
otherwise: Yup.string().nullable(),
|
otherwise: Yup.string().nullable(),
|
||||||
})
|
})
|
||||||
.matches(
|
.test(
|
||||||
// eslint-disable-next-line no-useless-escape
|
'valid-url',
|
||||||
/^(https?:)?\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i,
|
intl.formatMessage(messages.validationWebhookUrl),
|
||||||
intl.formatMessage(messages.validationWebhookUrl)
|
isValidURL
|
||||||
),
|
),
|
||||||
jsonPayload: Yup.string()
|
jsonPayload: Yup.string()
|
||||||
.when('enabled', {
|
.when('enabled', {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SensitiveInput from '@app/components/Common/SensitiveInput';
|
|||||||
import type { RadarrTestResponse } from '@app/components/Settings/SettingsServices';
|
import type { RadarrTestResponse } from '@app/components/Settings/SettingsServices';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { isValidURL } from '@app/utils/urlValidationHelper';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import type { RadarrSettings } from '@server/lib/settings';
|
import type { RadarrSettings } from '@server/lib/settings';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -117,9 +118,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
|||||||
intl.formatMessage(messages.validationMinimumAvailabilityRequired)
|
intl.formatMessage(messages.validationMinimumAvailabilityRequired)
|
||||||
),
|
),
|
||||||
externalUrl: Yup.string()
|
externalUrl: Yup.string()
|
||||||
.matches(
|
.test(
|
||||||
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
|
'valid-url',
|
||||||
intl.formatMessage(messages.validationApplicationUrl)
|
intl.formatMessage(messages.validationApplicationUrl),
|
||||||
|
isValidURL
|
||||||
)
|
)
|
||||||
.test(
|
.test(
|
||||||
'no-trailing-slash',
|
'no-trailing-slash',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import LibraryItem from '@app/components/Settings/LibraryItem';
|
|||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { isValidURL } from '@app/utils/urlValidationHelper';
|
||||||
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
||||||
import { ApiErrorCode } from '@server/constants/error';
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
@@ -140,10 +141,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
),
|
),
|
||||||
jellyfinExternalUrl: Yup.string()
|
jellyfinExternalUrl: Yup.string()
|
||||||
.nullable()
|
.nullable()
|
||||||
.matches(
|
.test('valid-url', intl.formatMessage(messages.validationUrl), isValidURL)
|
||||||
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
|
|
||||||
intl.formatMessage(messages.validationUrl)
|
|
||||||
)
|
|
||||||
.test(
|
.test(
|
||||||
'no-trailing-slash',
|
'no-trailing-slash',
|
||||||
intl.formatMessage(messages.validationUrlTrailingSlash),
|
intl.formatMessage(messages.validationUrlTrailingSlash),
|
||||||
@@ -151,10 +149,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
),
|
),
|
||||||
jellyfinForgotPasswordUrl: Yup.string()
|
jellyfinForgotPasswordUrl: Yup.string()
|
||||||
.nullable()
|
.nullable()
|
||||||
.matches(
|
.test('valid-url', intl.formatMessage(messages.validationUrl), isValidURL)
|
||||||
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
|
|
||||||
intl.formatMessage(messages.validationUrl)
|
|
||||||
)
|
|
||||||
.test(
|
.test(
|
||||||
'no-trailing-slash',
|
'no-trailing-slash',
|
||||||
intl.formatMessage(messages.validationUrlTrailingSlash),
|
intl.formatMessage(messages.validationUrlTrailingSlash),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import useLocale from '@app/hooks/useLocale';
|
|||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { isValidURL } from '@app/utils/urlValidationHelper';
|
||||||
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
||||||
import { ArrowPathIcon } from '@heroicons/react/24/solid';
|
import { ArrowPathIcon } from '@heroicons/react/24/solid';
|
||||||
import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces';
|
import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces';
|
||||||
@@ -45,11 +46,16 @@ const messages = defineMessages('components.Settings.SettingsMain', {
|
|||||||
'The "Process Blacklisted Tags" job will blacklist this many pages into each sort. Larger numbers will create a more accurate blacklist, but use more space.',
|
'The "Process Blacklisted Tags" job will blacklist this many pages into each sort. Larger numbers will create a more accurate blacklist, but use more space.',
|
||||||
streamingRegion: 'Streaming Region',
|
streamingRegion: 'Streaming Region',
|
||||||
streamingRegionTip: 'Show streaming sites by regional availability',
|
streamingRegionTip: 'Show streaming sites by regional availability',
|
||||||
|
hideBlacklisted: 'Hide Blacklisted Items',
|
||||||
|
hideBlacklistedTip:
|
||||||
|
'Hide blacklisted items from discover pages for all users with the "Manage Blacklist" permission',
|
||||||
toastApiKeySuccess: 'New API key generated successfully!',
|
toastApiKeySuccess: 'New API key generated successfully!',
|
||||||
toastApiKeyFailure: 'Something went wrong while generating a new API key.',
|
toastApiKeyFailure: 'Something went wrong while generating a new API key.',
|
||||||
toastSettingsSuccess: 'Settings saved successfully!',
|
toastSettingsSuccess: 'Settings saved successfully!',
|
||||||
toastSettingsFailure: 'Something went wrong while saving settings.',
|
toastSettingsFailure: 'Something went wrong while saving settings.',
|
||||||
hideAvailable: 'Hide Available Media',
|
hideAvailable: 'Hide Available Media',
|
||||||
|
hideAvailableTip:
|
||||||
|
'Hide available media from the discover pages but not search results',
|
||||||
cacheImages: 'Enable Image Caching',
|
cacheImages: 'Enable Image Caching',
|
||||||
cacheImagesTip:
|
cacheImagesTip:
|
||||||
'Cache externally sourced images (requires a significant amount of disk space)',
|
'Cache externally sourced images (requires a significant amount of disk space)',
|
||||||
@@ -59,6 +65,11 @@ const messages = defineMessages('components.Settings.SettingsMain', {
|
|||||||
partialRequestsEnabled: 'Allow Partial Series Requests',
|
partialRequestsEnabled: 'Allow Partial Series Requests',
|
||||||
enableSpecialEpisodes: 'Allow Special Episodes Requests',
|
enableSpecialEpisodes: 'Allow Special Episodes Requests',
|
||||||
locale: 'Display Language',
|
locale: 'Display Language',
|
||||||
|
youtubeUrl: 'YouTube URL',
|
||||||
|
youtubeUrlTip:
|
||||||
|
'Base URL for YouTube videos if a self-hosted YouTube instance is used.',
|
||||||
|
validationUrl: 'You must provide a valid URL',
|
||||||
|
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||||
});
|
});
|
||||||
|
|
||||||
const SettingsMain = () => {
|
const SettingsMain = () => {
|
||||||
@@ -80,9 +91,10 @@ const SettingsMain = () => {
|
|||||||
intl.formatMessage(messages.validationApplicationTitle)
|
intl.formatMessage(messages.validationApplicationTitle)
|
||||||
),
|
),
|
||||||
applicationUrl: Yup.string()
|
applicationUrl: Yup.string()
|
||||||
.matches(
|
.test(
|
||||||
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
|
'valid-url',
|
||||||
intl.formatMessage(messages.validationApplicationUrl)
|
intl.formatMessage(messages.validationApplicationUrl),
|
||||||
|
isValidURL
|
||||||
)
|
)
|
||||||
.test(
|
.test(
|
||||||
'no-trailing-slash',
|
'no-trailing-slash',
|
||||||
@@ -100,6 +112,13 @@ const SettingsMain = () => {
|
|||||||
'Number must be less than or equal to 250.',
|
'Number must be less than or equal to 250.',
|
||||||
(value) => (value ?? 0) <= 250
|
(value) => (value ?? 0) <= 250
|
||||||
),
|
),
|
||||||
|
youtubeUrl: Yup.string()
|
||||||
|
.url(intl.formatMessage(messages.validationUrl))
|
||||||
|
.test(
|
||||||
|
'no-trailing-slash',
|
||||||
|
intl.formatMessage(messages.validationUrlTrailingSlash),
|
||||||
|
(value) => !value || !value.endsWith('/')
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const regenerate = async () => {
|
const regenerate = async () => {
|
||||||
@@ -145,6 +164,7 @@ const SettingsMain = () => {
|
|||||||
applicationTitle: data?.applicationTitle,
|
applicationTitle: data?.applicationTitle,
|
||||||
applicationUrl: data?.applicationUrl,
|
applicationUrl: data?.applicationUrl,
|
||||||
hideAvailable: data?.hideAvailable,
|
hideAvailable: data?.hideAvailable,
|
||||||
|
hideBlacklisted: data?.hideBlacklisted,
|
||||||
locale: data?.locale ?? 'en',
|
locale: data?.locale ?? 'en',
|
||||||
discoverRegion: data?.discoverRegion,
|
discoverRegion: data?.discoverRegion,
|
||||||
originalLanguage: data?.originalLanguage,
|
originalLanguage: data?.originalLanguage,
|
||||||
@@ -154,6 +174,7 @@ const SettingsMain = () => {
|
|||||||
partialRequestsEnabled: data?.partialRequestsEnabled,
|
partialRequestsEnabled: data?.partialRequestsEnabled,
|
||||||
enableSpecialEpisodes: data?.enableSpecialEpisodes,
|
enableSpecialEpisodes: data?.enableSpecialEpisodes,
|
||||||
cacheImages: data?.cacheImages,
|
cacheImages: data?.cacheImages,
|
||||||
|
youtubeUrl: data?.youtubeUrl,
|
||||||
}}
|
}}
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
validationSchema={MainSettingsSchema}
|
validationSchema={MainSettingsSchema}
|
||||||
@@ -163,6 +184,7 @@ const SettingsMain = () => {
|
|||||||
applicationTitle: values.applicationTitle,
|
applicationTitle: values.applicationTitle,
|
||||||
applicationUrl: values.applicationUrl,
|
applicationUrl: values.applicationUrl,
|
||||||
hideAvailable: values.hideAvailable,
|
hideAvailable: values.hideAvailable,
|
||||||
|
hideBlacklisted: values.hideBlacklisted,
|
||||||
locale: values.locale,
|
locale: values.locale,
|
||||||
discoverRegion: values.discoverRegion,
|
discoverRegion: values.discoverRegion,
|
||||||
streamingRegion: values.streamingRegion,
|
streamingRegion: values.streamingRegion,
|
||||||
@@ -172,6 +194,7 @@ const SettingsMain = () => {
|
|||||||
partialRequestsEnabled: values.partialRequestsEnabled,
|
partialRequestsEnabled: values.partialRequestsEnabled,
|
||||||
enableSpecialEpisodes: values.enableSpecialEpisodes,
|
enableSpecialEpisodes: values.enableSpecialEpisodes,
|
||||||
cacheImages: values.cacheImages,
|
cacheImages: values.cacheImages,
|
||||||
|
youtubeUrl: values.youtubeUrl,
|
||||||
});
|
});
|
||||||
mutate('/api/v1/settings/public');
|
mutate('/api/v1/settings/public');
|
||||||
mutate('/api/v1/status');
|
mutate('/api/v1/status');
|
||||||
@@ -428,6 +451,9 @@ const SettingsMain = () => {
|
|||||||
{intl.formatMessage(messages.hideAvailable)}
|
{intl.formatMessage(messages.hideAvailable)}
|
||||||
</span>
|
</span>
|
||||||
<SettingsBadge badgeType="experimental" />
|
<SettingsBadge badgeType="experimental" />
|
||||||
|
<span className="label-tip">
|
||||||
|
{intl.formatMessage(messages.hideAvailableTip)}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input-area">
|
<div className="form-input-area">
|
||||||
<Field
|
<Field
|
||||||
@@ -440,6 +466,29 @@ const SettingsMain = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="hideBlacklisted" className="checkbox-label">
|
||||||
|
<span className="mr-2">
|
||||||
|
{intl.formatMessage(messages.hideBlacklisted)}
|
||||||
|
</span>
|
||||||
|
<span className="label-tip">
|
||||||
|
{intl.formatMessage(messages.hideBlacklistedTip)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field
|
||||||
|
type="checkbox"
|
||||||
|
id="hideBlacklisted"
|
||||||
|
name="hideBlacklisted"
|
||||||
|
onChange={() => {
|
||||||
|
setFieldValue(
|
||||||
|
'hideBlacklisted',
|
||||||
|
!values.hideBlacklisted
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label
|
<label
|
||||||
htmlFor="partialRequestsEnabled"
|
htmlFor="partialRequestsEnabled"
|
||||||
@@ -486,6 +535,29 @@ const SettingsMain = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="youtubeUrl" className="text-label">
|
||||||
|
{intl.formatMessage(messages.youtubeUrl)}
|
||||||
|
<span className="label-tip">
|
||||||
|
{intl.formatMessage(messages.youtubeUrlTip)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field
|
||||||
|
id="youtubeUrl"
|
||||||
|
name="youtubeUrl"
|
||||||
|
type="text"
|
||||||
|
inputMode="url"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.youtubeUrl &&
|
||||||
|
touched.youtubeUrl &&
|
||||||
|
typeof errors.youtubeUrl === 'string' && (
|
||||||
|
<div className="error">{errors.youtubeUrl}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ const messages = defineMessages('components.Settings.SettingsNetwork', {
|
|||||||
networkDisclaimer:
|
networkDisclaimer:
|
||||||
'Network parameters from your container/system should be used instead of these settings. See the {docs} for more information.',
|
'Network parameters from your container/system should be used instead of these settings. See the {docs} for more information.',
|
||||||
docs: 'documentation',
|
docs: 'documentation',
|
||||||
|
forceIpv4First: 'Force IPv4 Resolution First',
|
||||||
|
forceIpv4FirstTip:
|
||||||
|
'Force Jellyseerr to resolve IPv4 addresses first instead of IPv6',
|
||||||
});
|
});
|
||||||
|
|
||||||
const SettingsNetwork = () => {
|
const SettingsNetwork = () => {
|
||||||
@@ -86,6 +89,7 @@ const SettingsNetwork = () => {
|
|||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
csrfProtection: data?.csrfProtection,
|
csrfProtection: data?.csrfProtection,
|
||||||
|
forceIpv4First: data?.forceIpv4First,
|
||||||
trustProxy: data?.trustProxy,
|
trustProxy: data?.trustProxy,
|
||||||
proxyEnabled: data?.proxy?.enabled,
|
proxyEnabled: data?.proxy?.enabled,
|
||||||
proxyHostname: data?.proxy?.hostname,
|
proxyHostname: data?.proxy?.hostname,
|
||||||
@@ -102,6 +106,7 @@ const SettingsNetwork = () => {
|
|||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/network', {
|
await axios.post('/api/v1/settings/network', {
|
||||||
csrfProtection: values.csrfProtection,
|
csrfProtection: values.csrfProtection,
|
||||||
|
forceIpv4First: values.forceIpv4First,
|
||||||
trustProxy: values.trustProxy,
|
trustProxy: values.trustProxy,
|
||||||
proxy: {
|
proxy: {
|
||||||
enabled: values.proxyEnabled,
|
enabled: values.proxyEnabled,
|
||||||
@@ -193,6 +198,29 @@ const SettingsNetwork = () => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="forceIpv4First" className="checkbox-label">
|
||||||
|
<span className="mr-2">
|
||||||
|
{intl.formatMessage(messages.forceIpv4First)}
|
||||||
|
</span>
|
||||||
|
<SettingsBadge badgeType="advanced" className="mr-2" />
|
||||||
|
<SettingsBadge badgeType="restartRequired" />
|
||||||
|
<SettingsBadge badgeType="experimental" />
|
||||||
|
<span className="label-tip">
|
||||||
|
{intl.formatMessage(messages.forceIpv4FirstTip)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field
|
||||||
|
type="checkbox"
|
||||||
|
id="forceIpv4First"
|
||||||
|
name="forceIpv4First"
|
||||||
|
onChange={() => {
|
||||||
|
setFieldValue('forceIpv4First', !values.forceIpv4First);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="proxyEnabled" className="checkbox-label">
|
<label htmlFor="proxyEnabled" className="checkbox-label">
|
||||||
<span className="mr-2">
|
<span className="mr-2">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import DiscordLogo from '@app/assets/extlogos/discord.svg';
|
import DiscordLogo from '@app/assets/extlogos/discord.svg';
|
||||||
import GotifyLogo from '@app/assets/extlogos/gotify.svg';
|
import GotifyLogo from '@app/assets/extlogos/gotify.svg';
|
||||||
import LunaSeaLogo from '@app/assets/extlogos/lunasea.svg';
|
import LunaSeaLogo from '@app/assets/extlogos/lunasea.svg';
|
||||||
|
import NtfyLogo from '@app/assets/extlogos/ntfy.svg';
|
||||||
import PushbulletLogo from '@app/assets/extlogos/pushbullet.svg';
|
import PushbulletLogo from '@app/assets/extlogos/pushbullet.svg';
|
||||||
import PushoverLogo from '@app/assets/extlogos/pushover.svg';
|
import PushoverLogo from '@app/assets/extlogos/pushover.svg';
|
||||||
import SlackLogo from '@app/assets/extlogos/slack.svg';
|
import SlackLogo from '@app/assets/extlogos/slack.svg';
|
||||||
@@ -75,6 +76,17 @@ const SettingsNotifications = ({ children }: SettingsNotificationsProps) => {
|
|||||||
route: '/settings/notifications/gotify',
|
route: '/settings/notifications/gotify',
|
||||||
regex: /^\/settings\/notifications\/gotify/,
|
regex: /^\/settings\/notifications\/gotify/,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: 'ntfy.sh',
|
||||||
|
content: (
|
||||||
|
<span className="flex items-center">
|
||||||
|
<NtfyLogo className="mr-2 h-4" />
|
||||||
|
ntfy.sh
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
route: '/settings/notifications/ntfy',
|
||||||
|
regex: /^\/settings\/notifications\/ntfy/,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: 'LunaSea',
|
text: 'LunaSea',
|
||||||
content: (
|
content: (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import LibraryItem from '@app/components/Settings/LibraryItem';
|
|||||||
import SettingsBadge from '@app/components/Settings/SettingsBadge';
|
import SettingsBadge from '@app/components/Settings/SettingsBadge';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { isValidURL } from '@app/utils/urlValidationHelper';
|
||||||
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
||||||
import {
|
import {
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
@@ -191,9 +192,10 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
|
|||||||
otherwise: Yup.string().nullable(),
|
otherwise: Yup.string().nullable(),
|
||||||
}),
|
}),
|
||||||
tautulliExternalUrl: Yup.string()
|
tautulliExternalUrl: Yup.string()
|
||||||
.matches(
|
.test(
|
||||||
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
|
'valid-url',
|
||||||
intl.formatMessage(messages.validationUrl)
|
intl.formatMessage(messages.validationUrl),
|
||||||
|
isValidURL
|
||||||
)
|
)
|
||||||
.test(
|
.test(
|
||||||
'no-trailing-slash',
|
'no-trailing-slash',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SensitiveInput from '@app/components/Common/SensitiveInput';
|
|||||||
import type { SonarrTestResponse } from '@app/components/Settings/SettingsServices';
|
import type { SonarrTestResponse } from '@app/components/Settings/SettingsServices';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { isValidURL } from '@app/utils/urlValidationHelper';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import type { SonarrSettings } from '@server/lib/settings';
|
import type { SonarrSettings } from '@server/lib/settings';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -126,9 +127,10 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
)
|
)
|
||||||
: Yup.number(),
|
: Yup.number(),
|
||||||
externalUrl: Yup.string()
|
externalUrl: Yup.string()
|
||||||
.matches(
|
.test(
|
||||||
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
|
'valid-url',
|
||||||
intl.formatMessage(messages.validationApplicationUrl)
|
intl.formatMessage(messages.validationApplicationUrl),
|
||||||
|
isValidURL
|
||||||
)
|
)
|
||||||
.test(
|
.test(
|
||||||
'no-trailing-slash',
|
'no-trailing-slash',
|
||||||
|
|||||||
@@ -371,6 +371,17 @@ const StatusBadge = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case MediaStatus.DELETED:
|
||||||
|
return (
|
||||||
|
<Tooltip content={mediaLinkDescription}>
|
||||||
|
<Badge badgeType="danger">
|
||||||
|
{intl.formatMessage(is4k ? messages.status4k : messages.status, {
|
||||||
|
status: intl.formatMessage(globalMessages.deleted),
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -467,7 +467,9 @@ const TitleCard = ({
|
|||||||
<div
|
<div
|
||||||
className={`px-2 text-white ${
|
className={`px-2 text-white ${
|
||||||
!showRequestButton ||
|
!showRequestButton ||
|
||||||
(currentStatus && currentStatus !== MediaStatus.UNKNOWN)
|
(currentStatus &&
|
||||||
|
currentStatus !== MediaStatus.UNKNOWN &&
|
||||||
|
currentStatus !== MediaStatus.DELETED)
|
||||||
? 'pb-2'
|
? 'pb-2'
|
||||||
: 'pb-11'
|
: 'pb-11'
|
||||||
}`}
|
}`}
|
||||||
@@ -493,7 +495,8 @@ const TitleCard = ({
|
|||||||
WebkitLineClamp:
|
WebkitLineClamp:
|
||||||
!showRequestButton ||
|
!showRequestButton ||
|
||||||
(currentStatus &&
|
(currentStatus &&
|
||||||
currentStatus !== MediaStatus.UNKNOWN)
|
currentStatus !== MediaStatus.UNKNOWN &&
|
||||||
|
currentStatus !== MediaStatus.DELETED)
|
||||||
? 5
|
? 5
|
||||||
: 3,
|
: 3,
|
||||||
display: '-webkit-box',
|
display: '-webkit-box',
|
||||||
@@ -510,7 +513,9 @@ const TitleCard = ({
|
|||||||
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-2 py-2">
|
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-2 py-2">
|
||||||
{showRequestButton &&
|
{showRequestButton &&
|
||||||
(!currentStatus || currentStatus === MediaStatus.UNKNOWN) && (
|
(!currentStatus ||
|
||||||
|
currentStatus === MediaStatus.UNKNOWN ||
|
||||||
|
currentStatus === MediaStatus.DELETED) && (
|
||||||
<Button
|
<Button
|
||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
buttonSize="sm"
|
buttonSize="sm"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user