Compare commits
43 Commits
preview-li
...
logs-plex-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd42194f61 | ||
|
|
77a36f9714 | ||
|
|
f773e0fb2a | ||
|
|
767a24164d | ||
|
|
8394eb5ad4 | ||
|
|
b8425d6388 | ||
|
|
ebb7f00305 | ||
|
|
418d51590d | ||
|
|
a6dd4a8fed | ||
|
|
4d1163c343 | ||
|
|
b085e12ff9 | ||
|
|
33e7a153aa | ||
|
|
9891a7577c | ||
|
|
077e355c77 | ||
|
|
21ab20bba9 | ||
|
|
cdfb30ea16 | ||
|
|
771ecdf781 | ||
|
|
863b675c77 | ||
|
|
5b998bef82 | ||
|
|
0113612ced | ||
|
|
f8c9689745 | ||
|
|
af8d6b475c | ||
|
|
dcc13080bc | ||
|
|
e97a13e1e4 | ||
|
|
1de518d915 | ||
|
|
4e44282387 | ||
|
|
67bd639a43 | ||
|
|
ada467ecf4 | ||
|
|
9cc6930fed | ||
|
|
3b4d6bf5b8 | ||
|
|
07e4662205 | ||
|
|
4eddbaa71b | ||
|
|
27112be933 | ||
|
|
a790b1abcc | ||
|
|
f0a6055774 | ||
|
|
a3f4773a35 | ||
|
|
73d8efaa54 | ||
|
|
9712f56054 | ||
|
|
b1f07f0eb2 | ||
|
|
64f05bcad6 | ||
|
|
80927b9705 | ||
|
|
d563b36186 | ||
|
|
117617188e |
@@ -94,7 +94,8 @@
|
|||||||
"avatar_url": "https://avatars.githubusercontent.com/u/345752?v=4",
|
"avatar_url": "https://avatars.githubusercontent.com/u/345752?v=4",
|
||||||
"profile": "https://github.com/jab416171",
|
"profile": "https://github.com/jab416171",
|
||||||
"contributions": [
|
"contributions": [
|
||||||
"doc"
|
"doc",
|
||||||
|
"code"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -338,7 +339,8 @@
|
|||||||
"avatar_url": "https://avatars.githubusercontent.com/u/37781713?v=4",
|
"avatar_url": "https://avatars.githubusercontent.com/u/37781713?v=4",
|
||||||
"profile": "https://gauthierth.fr/",
|
"profile": "https://gauthierth.fr/",
|
||||||
"contributions": [
|
"contributions": [
|
||||||
"code"
|
"code",
|
||||||
|
"maintenance"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -610,6 +612,105 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"code"
|
"code"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "michaelhthomas",
|
||||||
|
"name": "Michael Thomas",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/18223295?v=4",
|
||||||
|
"profile": "http://michaelt.xyz",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "j0srisk",
|
||||||
|
"name": "Joseph Risk",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4",
|
||||||
|
"profile": "http://josephrisk.com",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Loetwiek",
|
||||||
|
"name": "Loetwiek",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4",
|
||||||
|
"profile": "https://github.com/Loetwiek",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Fuochi",
|
||||||
|
"name": "Fuochi",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4",
|
||||||
|
"profile": "https://github.com/Fuochi",
|
||||||
|
"contributions": [
|
||||||
|
"doc"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "demrich",
|
||||||
|
"name": "David Emrich",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/30092389?v=4",
|
||||||
|
"profile": "https://github.com/demrich",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "maxnatamo",
|
||||||
|
"name": "Max T. Kristiansen",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/5898152?v=4",
|
||||||
|
"profile": "https://maxtrier.dk",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "DamsDev1",
|
||||||
|
"name": "Damien Fajole",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/60252259?v=4",
|
||||||
|
"profile": "https://damsdev.me",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "AhmedNSidd",
|
||||||
|
"name": "Ahmed Siddiqui",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/36286128?v=4",
|
||||||
|
"profile": "https://github.com/AhmedNSidd",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"name": "RankWeis",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/733691?v=4",
|
||||||
|
"profile": "https://github.com/RankWeis",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
8
.github/workflows/cypress.yml
vendored
@@ -36,3 +36,11 @@ jobs:
|
|||||||
# Fix test titles in cypress dashboard
|
# Fix test titles in cypress dashboard
|
||||||
COMMIT_INFO_MESSAGE: ${{github.event.pull_request.title}}
|
COMMIT_INFO_MESSAGE: ${{github.event.pull_request.title}}
|
||||||
COMMIT_INFO_SHA: ${{github.event.pull_request.head.sha}}
|
COMMIT_INFO_SHA: ${{github.event.pull_request.head.sha}}
|
||||||
|
- name: Upload video files
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: cypress-videos
|
||||||
|
path: |
|
||||||
|
cypress/videos
|
||||||
|
cypress/screenshots
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ When adding new UI text, please try to adhere to the following guidelines:
|
|||||||
|
|
||||||
## Translation
|
## Translation
|
||||||
|
|
||||||
We use [Weblate](https://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/) for our translations, and your help with localizing Overseerr would be greatly appreciated! If your language is not listed below, please [open a feature request](https://github.com/fallenbagel/jellyseerr/issues/new/choose).
|
We use [Weblate](https://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/) for our translations, and your help with localizing Jellyseerr would be greatly appreciated! If your language is not listed below, please [open a feature request](https://github.com/fallenbagel/jellyseerr/issues/new/choose).
|
||||||
|
|
||||||
<a href="https://jellyseerr.borgcube.de/engage/jellysseerr/"><img src="https://jellyseerr.borgcube.de/widget/jellyseerr/multi-auto.svg" alt="Translation status" /></a>
|
<a href="https://jellyseerr.borgcube.de/engage/jellysseerr/"><img src="https://jellyseerr.borgcube.de/widget/jellyseerr/multi-auto.svg" alt="Translation status" /></a>
|
||||||
|
|
||||||
|
|||||||
23
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-66-orange.svg"/></a>
|
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-77-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/)**.
|
||||||
@@ -96,7 +96,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CyferShepard"><img src="https://avatars.githubusercontent.com/u/24864904?v=4?s=100" width="100px;" alt="Thegan Govender"/><br /><sub><b>Thegan Govender</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=CyferShepard" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CyferShepard"><img src="https://avatars.githubusercontent.com/u/24864904?v=4?s=100" width="100px;" alt="Thegan Govender"/><br /><sub><b>Thegan Govender</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=CyferShepard" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jab416171"><img src="https://avatars.githubusercontent.com/u/345752?v=4?s=100" width="100px;" alt="jab416171"/><br /><sub><b>jab416171</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jab416171" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jab416171"><img src="https://avatars.githubusercontent.com/u/345752?v=4?s=100" width="100px;" alt="jab416171"/><br /><sub><b>jab416171</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jab416171" title="Documentation">📖</a> <a href="https://github.com/fallenbagel/jellyseerr/commits?author=jab416171" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://nvds.be"><img src="https://avatars.githubusercontent.com/u/5257222?v=4?s=100" width="100px;" alt="Nicolai Van der Storm"/><br /><sub><b>Nicolai Van der Storm</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=NicolaiVdS" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://nvds.be"><img src="https://avatars.githubusercontent.com/u/5257222?v=4?s=100" width="100px;" alt="Nicolai Van der Storm"/><br /><sub><b>Nicolai Van der Storm</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=NicolaiVdS" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Smexhy"><img src="https://avatars.githubusercontent.com/u/4880625?v=4?s=100" width="100px;" alt="Smexhy"/><br /><sub><b>Smexhy</b></sub></a><br /><a href="#translation-Smexhy" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Smexhy"><img src="https://avatars.githubusercontent.com/u/4880625?v=4?s=100" width="100px;" alt="Smexhy"/><br /><sub><b>Smexhy</b></sub></a><br /><a href="#translation-Smexhy" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://dd06-dev.fr"><img src="https://avatars.githubusercontent.com/u/58089504?v=4?s=100" width="100px;" alt="dd060606"/><br /><sub><b>dd060606</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=dd060606" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://dd06-dev.fr"><img src="https://avatars.githubusercontent.com/u/58089504?v=4?s=100" width="100px;" alt="dd060606"/><br /><sub><b>dd060606</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=dd060606" title="Code">💻</a></td>
|
||||||
@@ -131,7 +131,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
<td align="center" valign="top" width="14.28%"><a href="https://www.boniface.me"><img src="https://avatars.githubusercontent.com/u/4031396?v=4?s=100" width="100px;" alt="Joshua M. Boniface"/><br /><sub><b>Joshua M. Boniface</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=joshuaboniface" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://www.boniface.me"><img src="https://avatars.githubusercontent.com/u/4031396?v=4?s=100" width="100px;" alt="Joshua M. Boniface"/><br /><sub><b>Joshua M. Boniface</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=joshuaboniface" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=gauthier-th" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=gauthier-th" title="Code">💻</a> <a href="#maintenance-gauthier-th" title="Maintenance">🚧</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Kara-Zor-El"><img src="https://avatars.githubusercontent.com/u/69772087?v=4?s=100" width="100px;" alt="Kara"/><br /><sub><b>Kara</b></sub></a><br /><a href="#infra-Kara-Zor-El" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Kara-Zor-El"><img src="https://avatars.githubusercontent.com/u/69772087?v=4?s=100" width="100px;" alt="Kara"/><br /><sub><b>Kara</b></sub></a><br /><a href="#infra-Kara-Zor-El" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td>
|
||||||
@@ -170,6 +170,19 @@ 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/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>
|
||||||
|
<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>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -287,7 +300,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/byakurau"><img src="https://avatars.githubusercontent.com/u/1811683?v=4?s=100" width="100px;" alt="byakurau"/><br /><sub><b>byakurau</b></sub></a><br /><a href="#translation-byakurau" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/byakurau"><img src="https://avatars.githubusercontent.com/u/1811683?v=4?s=100" width="100px;" alt="byakurau"/><br /><sub><b>byakurau</b></sub></a><br /><a href="#translation-byakurau" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/miknii"><img src="https://avatars.githubusercontent.com/u/109232569?v=4?s=100" width="100px;" alt="miknii"/><br /><sub><b>miknii</b></sub></a><br /><a href="#translation-miknii" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/miknii"><img src="https://avatars.githubusercontent.com/u/109232569?v=4?s=100" width="100px;" alt="miknii"/><br /><sub><b>miknii</b></sub></a><br /><a href="#translation-miknii" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Eclipseop"><img src="https://avatars.githubusercontent.com/u/5846213?v=4?s=100" width="100px;" alt="Mackenzie"/><br /><sub><b>Mackenzie</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Eclipseop" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Eclipseop"><img src="https://avatars.githubusercontent.com/u/5846213?v=4?s=100" width="100px;" alt="Mackenzie"/><br /><sub><b>Mackenzie</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Eclipseop" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/s0up4200"><img src="https://avatars.githubusercontent.com/u/18177310?v=4?s=100" width="100px;" alt="soup"/><br /><sub><b>soup</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=s0up4200" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/s0up4200"><img src="https://avatars.githubusercontent.com/u/18177310?v=4?s=100" width="100px;" alt="soup"/><br /><sub><b>soup</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=s0up4200" title="Documentation">📖</a> <a href="https://github.com/sct/overseerr/commits?author=s0up4200" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ceptonit"><img src="https://avatars.githubusercontent.com/u/12678743?v=4?s=100" width="100px;" alt="ceptonit"/><br /><sub><b>ceptonit</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ceptonit" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ceptonit"><img src="https://avatars.githubusercontent.com/u/12678743?v=4?s=100" width="100px;" alt="ceptonit"/><br /><sub><b>ceptonit</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ceptonit" title="Documentation">📖</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/aedelbro"><img src="https://avatars.githubusercontent.com/u/36162221?v=4?s=100" width="100px;" alt="aedelbro"/><br /><sub><b>aedelbro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=aedelbro" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/aedelbro"><img src="https://avatars.githubusercontent.com/u/36162221?v=4?s=100" width="100px;" alt="aedelbro"/><br /><sub><b>aedelbro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=aedelbro" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -320,6 +333,8 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JackW6809"><img src="https://avatars.githubusercontent.com/u/53652452?v=4?s=100" width="100px;" alt="JackOXI"/><br /><sub><b>JackOXI</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JackW6809" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://indicus.ro"><img src="https://avatars.githubusercontent.com/u/1199404?v=4?s=100" width="100px;" alt="Stancu Florin"/><br /><sub><b>Stancu Florin</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=StancuFlorin" title="Code">💻</a></td>
|
||||||
</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.1.1
|
version: 2.3.0
|
||||||
appVersion: "2.3.0"
|
appVersion: "2.5.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
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export default defineConfig({
|
|||||||
projectId: 'xkm1b4',
|
projectId: 'xkm1b4',
|
||||||
e2e: {
|
e2e: {
|
||||||
baseUrl: 'http://localhost:5055',
|
baseUrl: 'http://localhost:5055',
|
||||||
|
video: true,
|
||||||
experimentalSessionAndOrigin: true,
|
experimentalSessionAndOrigin: true,
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs",
|
"vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs",
|
||||||
"main": {
|
"main": {
|
||||||
"apiKey": "testkey",
|
"apiKey": "testkey",
|
||||||
"applicationTitle": "Overseerr",
|
"applicationTitle": "Jellyseerr",
|
||||||
"applicationUrl": "",
|
"applicationUrl": "",
|
||||||
"csrfProtection": false,
|
"csrfProtection": false,
|
||||||
"cacheImages": false,
|
"cacheImages": false,
|
||||||
@@ -24,7 +24,6 @@
|
|||||||
"partialRequestsEnabled": true,
|
"partialRequestsEnabled": true,
|
||||||
"enableSpecialEpisodes": false,
|
"enableSpecialEpisodes": false,
|
||||||
"forceIpv4First": false,
|
"forceIpv4First": false,
|
||||||
"dnsServers": "",
|
|
||||||
"locale": "en"
|
"locale": "en"
|
||||||
},
|
},
|
||||||
"plex": {
|
"plex": {
|
||||||
@@ -71,7 +70,7 @@
|
|||||||
"ignoreTls": false,
|
"ignoreTls": false,
|
||||||
"requireTls": false,
|
"requireTls": false,
|
||||||
"allowSelfSigned": false,
|
"allowSelfSigned": false,
|
||||||
"senderName": "Overseerr"
|
"senderName": "Jellyseerr"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"discord": {
|
"discord": {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Cypress.Commands.add('login', (email, password) => {
|
|||||||
[email, password],
|
[email, password],
|
||||||
() => {
|
() => {
|
||||||
cy.visit('/login');
|
cy.visit('/login');
|
||||||
cy.contains('Use your Overseerr account').click();
|
|
||||||
|
|
||||||
cy.get('[data-testid=email]').type(email);
|
cy.get('[data-testid=email]').type(email);
|
||||||
cy.get('[data-testid=password]').type(password);
|
cy.get('[data-testid=password]').type(password);
|
||||||
|
|||||||
@@ -255,7 +255,8 @@ To run jellyseerr as a service:
|
|||||||
1. Download the [Non-Sucking Service Manager](https://nssm.cc/download)
|
1. Download the [Non-Sucking Service Manager](https://nssm.cc/download)
|
||||||
2. Install NSSM:
|
2. Install NSSM:
|
||||||
```powershell
|
```powershell
|
||||||
nssm install Jellyseerr "C:\Program Files\nodejs\node.exe" ["C:\jellyseerr\dist\index.js"]
|
nssm install Jellyseerr "C:\Program Files\nodejs\node.exe" "C:\jellyseerr\dist\index.js"
|
||||||
|
nssm set Jellyseerr AppDirectory "C:\jellyseerr"
|
||||||
nssm set Jellyseerr AppEnvironmentExtra NODE_ENV=production
|
nssm set Jellyseerr AppEnvironmentExtra NODE_ENV=production
|
||||||
```
|
```
|
||||||
3. Start the service:
|
3. Start the service:
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ or for Cloudflare's DNS:
|
|||||||
```bash
|
```bash
|
||||||
--dns=1.1.1.1
|
--dns=1.1.1.1
|
||||||
```
|
```
|
||||||
|
or for Quad9 DNS:
|
||||||
|
```bash
|
||||||
|
--dns=9.9.9.9
|
||||||
|
```
|
||||||
|
|
||||||
|
You can try them all and see which one works for your network.
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
@@ -45,6 +51,16 @@ services:
|
|||||||
dns:
|
dns:
|
||||||
- 1.1.1.1
|
- 1.1.1.1
|
||||||
```
|
```
|
||||||
|
or for Quad9's DNS:
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
services:
|
||||||
|
jellyseerr:
|
||||||
|
dns:
|
||||||
|
- 9.9.9.9
|
||||||
|
```
|
||||||
|
|
||||||
|
You can try them all and see which one works for your network.
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
@@ -56,7 +72,7 @@ services:
|
|||||||
4. Click on Change adapter settings.
|
4. Click on Change adapter settings.
|
||||||
5. Right-click the network interface connected to the internet and select Properties.
|
5. Right-click the network interface connected to the internet and select Properties.
|
||||||
6. Select Internet Protocol Version 4 (TCP/IPv4) and click Properties.
|
6. Select Internet Protocol Version 4 (TCP/IPv4) and click Properties.
|
||||||
7. Select Use the following DNS server addresses and enter `8.8.8.8` for Google's DNS or `1.1.1.1` for Cloudflare's DNS.
|
7. Select Use the following DNS server addresses and enter `8.8.8.8` for Google's DNS or `1.1.1.1` for Cloudflare's DNS or `9.9.9.9` for Quad9's DNS.
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
@@ -73,6 +89,10 @@ services:
|
|||||||
```bash
|
```bash
|
||||||
nameserver 1.1.1.1
|
nameserver 1.1.1.1
|
||||||
```
|
```
|
||||||
|
or for Quad9's DNS:
|
||||||
|
```bash
|
||||||
|
nameserver 9.9.9.9
|
||||||
|
```
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -81,7 +101,7 @@ services:
|
|||||||
|
|
||||||
Sometimes there are configuration issues with IPV6 that prevent the hostname resolution from working correctly.
|
Sometimes there are configuration issues with IPV6 that prevent the hostname resolution from working correctly.
|
||||||
|
|
||||||
You can try to force the resolution to use IPV4 first by setting the `FORCE_IPV4_FIRST` environment variable to `true`:
|
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. You can also add the environment variable, `FORCE_IPV4_FIRST=true`:
|
||||||
|
|
||||||
<Tabs groupId="methods" queryString>
|
<Tabs groupId="methods" queryString>
|
||||||
<TabItem value="docker-cli" label="Docker CLI">
|
<TabItem value="docker-cli" label="Docker CLI">
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ When disabled, your mediaserver OAuth becomes the only sign-in option, and any "
|
|||||||
|
|
||||||
This setting is **enabled** by default.
|
This setting is **enabled** by default.
|
||||||
|
|
||||||
|
## Enable Jellyfin/Emby/Plex Sign-In
|
||||||
|
|
||||||
|
When enabled, users will be able to sign in to Jellyseerr using their Jellyfin/Emby/Plex credentials, provided they have linked their media server accounts.
|
||||||
|
|
||||||
|
When disabled, users will only be able to sign in using their email address. Users without a password set will not be able to sign in to Jellyseerr.
|
||||||
|
|
||||||
|
This setting is **enabled** by default.
|
||||||
|
|
||||||
## Enable New Jellyfin/Emby/Plex Sign-In
|
## Enable New Jellyfin/Emby/Plex Sign-In
|
||||||
|
|
||||||
When enabled, users with access to your media server will be able to sign in to Jellyseerr even if they have not yet been imported. Users will be automatically assigned the permissions configured in the [Default Permissions](#default-permissions) setting upon first sign-in.
|
When enabled, users with access to your media server will be able to sign in to Jellyseerr even if they have not yet been imported. Users will be automatically assigned the permissions configured in the [Default Permissions](#default-permissions) setting upon first sign-in.
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
openapi: '3.0.2'
|
openapi: '3.0.2'
|
||||||
info:
|
info:
|
||||||
title: 'Overseerr API'
|
title: 'Jellyseerr API'
|
||||||
version: '1.0.0'
|
version: '1.0.0'
|
||||||
description: |
|
description: |
|
||||||
This is the documentation for the Overseerr API backend.
|
This is the documentation for the Jellyseerr API backend.
|
||||||
|
|
||||||
Two primary authentication methods are supported:
|
Two primary authentication methods are supported:
|
||||||
|
|
||||||
- **Cookie Authentication**: A valid sign-in to the `/auth/plex` or `/auth/local` will generate a valid authentication cookie.
|
- **Cookie Authentication**: A valid sign-in to the `/auth/plex` or `/auth/local` will generate a valid authentication cookie.
|
||||||
- **API Key Authentication**: Sign-in is also possible by passing an `X-Api-Key` header along with a valid API Key generated by Overseerr.
|
- **API Key Authentication**: Sign-in is also possible by passing an `X-Api-Key` header along with a valid API Key generated by Jellyseerr.
|
||||||
tags:
|
tags:
|
||||||
- name: public
|
- name: public
|
||||||
description: Public API endpoints requiring no authentication.
|
description: Public API endpoints requiring no authentication.
|
||||||
- name: settings
|
- name: settings
|
||||||
description: Endpoints related to Overseerr's settings and configuration.
|
description: Endpoints related to Jellyseerr's settings and configuration.
|
||||||
- name: auth
|
- name: auth
|
||||||
description: Endpoints related to logging in or out, and the currently authenticated user.
|
description: Endpoints related to logging in or out, and the currently authenticated user.
|
||||||
- name: users
|
- name: users
|
||||||
@@ -160,7 +160,7 @@ components:
|
|||||||
example: en
|
example: en
|
||||||
applicationTitle:
|
applicationTitle:
|
||||||
type: string
|
type: string
|
||||||
example: Overseerr
|
example: Jellyseerr
|
||||||
applicationUrl:
|
applicationUrl:
|
||||||
type: string
|
type: string
|
||||||
example: https://os.example.com
|
example: https://os.example.com
|
||||||
@@ -194,9 +194,6 @@ components:
|
|||||||
forceIpv4First:
|
forceIpv4First:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: false
|
example: false
|
||||||
dnsServers:
|
|
||||||
type: string
|
|
||||||
example: '1.1.1.1'
|
|
||||||
trustProxy:
|
trustProxy:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: true
|
example: true
|
||||||
@@ -1438,7 +1435,7 @@ components:
|
|||||||
example: no-reply@example.com
|
example: no-reply@example.com
|
||||||
senderName:
|
senderName:
|
||||||
type: string
|
type: string
|
||||||
example: Overseerr
|
example: Jellyseerr
|
||||||
smtpHost:
|
smtpHost:
|
||||||
type: string
|
type: string
|
||||||
example: 127.0.0.1
|
example: 127.0.0.1
|
||||||
@@ -1969,8 +1966,8 @@ components:
|
|||||||
paths:
|
paths:
|
||||||
/status:
|
/status:
|
||||||
get:
|
get:
|
||||||
summary: Get Overseerr status
|
summary: Get Jellyseerr status
|
||||||
description: Returns the current Overseerr status in a JSON object.
|
description: Returns the current Jellyseerr status in a JSON object.
|
||||||
security: []
|
security: []
|
||||||
tags:
|
tags:
|
||||||
- public
|
- public
|
||||||
@@ -3815,6 +3812,11 @@ paths:
|
|||||||
required: false
|
required: false
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: includeIds
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: A JSON array of all users
|
description: A JSON array of all users
|
||||||
@@ -4423,6 +4425,104 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'204':
|
'204':
|
||||||
description: User password updated
|
description: User password updated
|
||||||
|
/user/{userId}/settings/linked-accounts/plex:
|
||||||
|
post:
|
||||||
|
summary: Link the provided Plex account to the current user
|
||||||
|
description: Logs in to Plex with the provided auth token, then links the associated Plex account with the user's account. Users can only link external accounts to their own account.
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: userId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
authToken:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- authToken
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Linking account succeeded
|
||||||
|
'403':
|
||||||
|
description: Invalid credentials
|
||||||
|
'422':
|
||||||
|
description: Account already linked to a user
|
||||||
|
delete:
|
||||||
|
summary: Remove the linked Plex account for a user
|
||||||
|
description: Removes the linked Plex account for a specific user. Requires `MANAGE_USERS` permission if editing other users.
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: userId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Unlinking account succeeded
|
||||||
|
'400':
|
||||||
|
description: Unlink request invalid
|
||||||
|
'404':
|
||||||
|
description: User does not exist
|
||||||
|
/user/{userId}/settings/linked-accounts/jellyfin:
|
||||||
|
post:
|
||||||
|
summary: Link the provided Jellyfin account to the current user
|
||||||
|
description: Logs in to Jellyfin with the provided credentials, then links the associated Jellyfin account with the user's account. Users can only link external accounts to their own account.
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: userId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
example: 'Mr User'
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
example: 'supersecret'
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Linking account succeeded
|
||||||
|
'403':
|
||||||
|
description: Invalid credentials
|
||||||
|
'422':
|
||||||
|
description: Account already linked to a user
|
||||||
|
delete:
|
||||||
|
summary: Remove the linked Jellyfin account for a user
|
||||||
|
description: Removes the linked Jellyfin account for a specific user. Requires `MANAGE_USERS` permission if editing other users.
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: userId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Unlinking account succeeded
|
||||||
|
'400':
|
||||||
|
description: Unlink request invalid
|
||||||
|
'404':
|
||||||
|
description: User does not exist
|
||||||
/user/{userId}/settings/notifications:
|
/user/{userId}/settings/notifications:
|
||||||
get:
|
get:
|
||||||
summary: Get notification settings for a user
|
summary: Get notification settings for a user
|
||||||
2
next-env.d.ts
vendored
@@ -2,4 +2,4 @@
|
|||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
|
||||||
|
|||||||
38
package.json
@@ -5,7 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"postinstall": "node postinstall-win.js",
|
"postinstall": "node postinstall-win.js",
|
||||||
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
|
"dev": "nodemon -e ts --watch server --watch jellyseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
|
||||||
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json",
|
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json",
|
||||||
"build:next": "next build",
|
"build:next": "next build",
|
||||||
"build": "pnpm build:next && pnpm build:server",
|
"build": "pnpm build:next && pnpm build:server",
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dr.pogodin/csurf": "^1.14.1",
|
||||||
"@formatjs/intl-displaynames": "6.2.6",
|
"@formatjs/intl-displaynames": "6.2.6",
|
||||||
"@formatjs/intl-locale": "3.1.1",
|
"@formatjs/intl-locale": "3.1.1",
|
||||||
"@formatjs/intl-pluralrules": "5.1.10",
|
"@formatjs/intl-pluralrules": "5.1.10",
|
||||||
@@ -47,16 +48,15 @@
|
|||||||
"bcrypt": "5.1.0",
|
"bcrypt": "5.1.0",
|
||||||
"bowser": "2.11.0",
|
"bowser": "2.11.0",
|
||||||
"connect-typeorm": "1.1.4",
|
"connect-typeorm": "1.1.4",
|
||||||
"cookie-parser": "1.4.6",
|
"cookie-parser": "1.4.7",
|
||||||
"copy-to-clipboard": "3.3.3",
|
"copy-to-clipboard": "3.3.3",
|
||||||
"country-flag-icons": "1.5.5",
|
"country-flag-icons": "1.5.5",
|
||||||
"cronstrue": "2.23.0",
|
"cronstrue": "2.23.0",
|
||||||
"csurf": "1.11.0",
|
|
||||||
"date-fns": "2.29.3",
|
"date-fns": "2.29.3",
|
||||||
"dayjs": "1.11.7",
|
"dayjs": "1.11.7",
|
||||||
"email-templates": "9.0.0",
|
"email-templates": "12.0.1",
|
||||||
"email-validator": "2.0.4",
|
"email-validator": "2.0.4",
|
||||||
"express": "4.18.2",
|
"express": "4.21.2",
|
||||||
"express-openapi-validator": "4.13.8",
|
"express-openapi-validator": "4.13.8",
|
||||||
"express-rate-limit": "6.7.0",
|
"express-rate-limit": "6.7.0",
|
||||||
"express-session": "1.17.3",
|
"express-session": "1.17.3",
|
||||||
@@ -64,15 +64,15 @@
|
|||||||
"gravatar-url": "3.1.0",
|
"gravatar-url": "3.1.0",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"mime": "3",
|
"mime": "3",
|
||||||
"next": "^14.2.4",
|
"next": "^14.2.24",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"node-gyp": "9.3.1",
|
"node-gyp": "9.3.1",
|
||||||
"node-schedule": "2.1.1",
|
"node-schedule": "2.1.1",
|
||||||
"nodemailer": "6.9.1",
|
"nodemailer": "6.10.0",
|
||||||
"openpgp": "5.7.0",
|
"openpgp": "5.11.2",
|
||||||
"pg": "8.11.0",
|
"pg": "8.11.0",
|
||||||
"plex-api": "5.3.2",
|
"plex-api": "5.3.2",
|
||||||
"pug": "3.0.2",
|
"pug": "3.0.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-ace": "10.1.0",
|
"react-ace": "10.1.0",
|
||||||
"react-animate-height": "2.1.2",
|
"react-animate-height": "2.1.2",
|
||||||
@@ -86,17 +86,19 @@
|
|||||||
"react-spring": "9.7.1",
|
"react-spring": "9.7.1",
|
||||||
"react-tailwindcss-datepicker-sct": "1.3.4",
|
"react-tailwindcss-datepicker-sct": "1.3.4",
|
||||||
"react-toast-notifications": "2.5.1",
|
"react-toast-notifications": "2.5.1",
|
||||||
|
"react-transition-group": "^4.4.5",
|
||||||
"react-truncate-markup": "5.1.2",
|
"react-truncate-markup": "5.1.2",
|
||||||
"react-use-clipboard": "1.0.9",
|
"react-use-clipboard": "1.0.9",
|
||||||
"reflect-metadata": "0.1.13",
|
"reflect-metadata": "0.1.13",
|
||||||
"secure-random-password": "0.2.3",
|
"secure-random-password": "0.2.3",
|
||||||
"semver": "7.3.8",
|
"semver": "7.7.1",
|
||||||
"sharp": "^0.33.4",
|
"sharp": "^0.33.4",
|
||||||
"sqlite3": "5.1.4",
|
"sqlite3": "5.1.7",
|
||||||
"swagger-ui-express": "4.6.2",
|
"swagger-ui-express": "4.6.2",
|
||||||
"swr": "2.2.5",
|
"swr": "2.2.5",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
"typeorm": "0.3.11",
|
"typeorm": "0.3.11",
|
||||||
"undici": "^6.20.1",
|
"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",
|
||||||
@@ -104,7 +106,7 @@
|
|||||||
"xml2js": "0.4.23",
|
"xml2js": "0.4.23",
|
||||||
"yamljs": "0.3.0",
|
"yamljs": "0.3.0",
|
||||||
"yup": "0.32.11",
|
"yup": "0.32.11",
|
||||||
"zod": "3.20.6"
|
"zod": "3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "17.4.4",
|
"@commitlint/cli": "17.4.4",
|
||||||
@@ -114,8 +116,8 @@
|
|||||||
"@semantic-release/exec": "6.0.3",
|
"@semantic-release/exec": "6.0.3",
|
||||||
"@semantic-release/git": "10.0.1",
|
"@semantic-release/git": "10.0.1",
|
||||||
"@tailwindcss/aspect-ratio": "0.4.2",
|
"@tailwindcss/aspect-ratio": "0.4.2",
|
||||||
"@tailwindcss/forms": "0.5.3",
|
"@tailwindcss/forms": "0.5.10",
|
||||||
"@tailwindcss/typography": "0.5.9",
|
"@tailwindcss/typography": "0.5.16",
|
||||||
"@types/bcrypt": "5.0.0",
|
"@types/bcrypt": "5.0.0",
|
||||||
"@types/cookie-parser": "1.4.3",
|
"@types/cookie-parser": "1.4.3",
|
||||||
"@types/country-flag-icons": "1.2.0",
|
"@types/country-flag-icons": "1.2.0",
|
||||||
@@ -144,7 +146,7 @@
|
|||||||
"commitizen": "4.3.0",
|
"commitizen": "4.3.0",
|
||||||
"copyfiles": "2.4.1",
|
"copyfiles": "2.4.1",
|
||||||
"cy-mobile-commands": "0.3.0",
|
"cy-mobile-commands": "0.3.0",
|
||||||
"cypress": "12.7.0",
|
"cypress": "14.1.0",
|
||||||
"cz-conventional-changelog": "3.3.0",
|
"cz-conventional-changelog": "3.3.0",
|
||||||
"eslint": "8.35.0",
|
"eslint": "8.35.0",
|
||||||
"eslint-config-next": "^14.2.4",
|
"eslint-config-next": "^14.2.4",
|
||||||
@@ -157,8 +159,8 @@
|
|||||||
"eslint-plugin-react-hooks": "4.6.0",
|
"eslint-plugin-react-hooks": "4.6.0",
|
||||||
"husky": "8.0.3",
|
"husky": "8.0.3",
|
||||||
"lint-staged": "13.1.2",
|
"lint-staged": "13.1.2",
|
||||||
"nodemon": "2.0.20",
|
"nodemon": "3.1.9",
|
||||||
"postcss": "8.4.21",
|
"postcss": "8.4.31",
|
||||||
"prettier": "2.8.4",
|
"prettier": "2.8.4",
|
||||||
"prettier-plugin-organize-imports": "3.2.2",
|
"prettier-plugin-organize-imports": "3.2.2",
|
||||||
"prettier-plugin-tailwindcss": "0.2.3",
|
"prettier-plugin-tailwindcss": "0.2.3",
|
||||||
|
|||||||
2789
pnpm-lock.yaml
generated
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
21
public/sw.js
@@ -3,7 +3,7 @@
|
|||||||
// previously cached resources to be updated from the network.
|
// previously cached resources to be updated from the network.
|
||||||
// This variable is intentionally declared and unused.
|
// This variable is intentionally declared and unused.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const OFFLINE_VERSION = 3;
|
const OFFLINE_VERSION = 4;
|
||||||
const CACHE_NAME = 'offline';
|
const CACHE_NAME = 'offline';
|
||||||
// Customize this with a different URL if needed.
|
// Customize this with a different URL if needed.
|
||||||
const OFFLINE_URL = '/offline.html';
|
const OFFLINE_URL = '/offline.html';
|
||||||
@@ -107,6 +107,25 @@ self.addEventListener('push', (event) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set the badge with the amount of pending requests
|
||||||
|
// Only update the badge if the payload confirms they are the admin
|
||||||
|
if (
|
||||||
|
(payload.notificationType === 'MEDIA_APPROVED' ||
|
||||||
|
payload.notificationType === 'MEDIA_DECLINED') &&
|
||||||
|
payload.isAdmin
|
||||||
|
) {
|
||||||
|
if ('setAppBadge' in navigator) {
|
||||||
|
navigator.setAppBadge(payload.pendingRequestsCount);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.notificationType === 'MEDIA_PENDING') {
|
||||||
|
if ('setAppBadge' in navigator) {
|
||||||
|
navigator.setAppBadge(payload.pendingRequestsCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
event.waitUntil(self.registration.showNotification(payload.subject, options));
|
event.waitUntil(self.registration.showNotification(payload.subject, options));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class GithubAPI extends ExternalAPI {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getOverseerrReleases({
|
public async getJellyseerrReleases({
|
||||||
take = 20,
|
take = 20,
|
||||||
}: {
|
}: {
|
||||||
take?: number;
|
take?: number;
|
||||||
@@ -88,14 +88,14 @@ class GithubAPI extends ExternalAPI {
|
|||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
"Failed to retrieve GitHub releases. This may be an issue on GitHub's end. Overseerr can't check if it's on the latest version.",
|
"Failed to retrieve GitHub releases. This may be an issue on GitHub's end. Jellyseerr can't check if it's on the latest version.",
|
||||||
{ label: 'GitHub API', errorMessage: e.message }
|
{ label: 'GitHub API', errorMessage: e.message }
|
||||||
);
|
);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getOverseerrCommits({
|
public async getJellyseerrCommits({
|
||||||
take = 20,
|
take = 20,
|
||||||
branch = 'develop',
|
branch = 'develop',
|
||||||
}: {
|
}: {
|
||||||
@@ -114,7 +114,7 @@ class GithubAPI extends ExternalAPI {
|
|||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
"Failed to retrieve GitHub commits. This may be an issue on GitHub's end. Overseerr can't check if it's on the latest version.",
|
"Failed to retrieve GitHub commits. This may be an issue on GitHub's end. Jellyseerr can't check if it's on the latest version.",
|
||||||
{ label: 'GitHub API', errorMessage: e.message }
|
{ label: 'GitHub API', errorMessage: e.message }
|
||||||
);
|
);
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import ExternalAPI from '@server/api/externalapi';
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
import { ApiErrorCode } from '@server/constants/error';
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import availabilitySync from '@server/lib/availabilitySync';
|
import availabilitySync from '@server/lib/availabilitySync';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { ApiError } from '@server/types/error';
|
import { ApiError } from '@server/types/error';
|
||||||
import { getAppVersion } from '@server/utils/appVersion';
|
import { getAppVersion } from '@server/utils/appVersion';
|
||||||
@@ -92,10 +94,22 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
|
|||||||
DateCreated?: string;
|
DateCreated?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JellyfinItemsReponse {
|
||||||
|
Items: JellyfinLibraryItemExtended[];
|
||||||
|
TotalRecordCount: number;
|
||||||
|
StartIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
class JellyfinAPI extends ExternalAPI {
|
class JellyfinAPI extends ExternalAPI {
|
||||||
private userId?: string;
|
private userId?: string;
|
||||||
|
private mediaServerType: MediaServerType;
|
||||||
|
|
||||||
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
|
constructor(
|
||||||
|
jellyfinHost: string,
|
||||||
|
authToken?: string | null,
|
||||||
|
deviceId?: string | null
|
||||||
|
) {
|
||||||
|
const settings = getSettings();
|
||||||
let authHeaderVal: string;
|
let authHeaderVal: string;
|
||||||
if (authToken) {
|
if (authToken) {
|
||||||
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`;
|
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`;
|
||||||
@@ -112,6 +126,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.mediaServerType = settings.main.mediaServerType;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async login(
|
public async login(
|
||||||
@@ -292,18 +308,15 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
|
|
||||||
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
|
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
|
||||||
try {
|
try {
|
||||||
const libraryItemsResponse = await this.get<any>(
|
const libraryItemsResponse = await this.get<any>(`/Items`, {
|
||||||
`/Users/${this.userId}/Items`,
|
SortBy: 'SortName',
|
||||||
{
|
SortOrder: 'Ascending',
|
||||||
SortBy: 'SortName',
|
IncludeItemTypes: 'Series,Movie,Others',
|
||||||
SortOrder: 'Ascending',
|
Recursive: 'true',
|
||||||
IncludeItemTypes: 'Series,Movie,Others',
|
StartIndex: '0',
|
||||||
Recursive: 'true',
|
ParentId: id,
|
||||||
StartIndex: '0',
|
collapseBoxSetItems: 'false',
|
||||||
ParentId: id,
|
});
|
||||||
collapseBoxSetItems: 'false',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return libraryItemsResponse.Items.filter(
|
return libraryItemsResponse.Items.filter(
|
||||||
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
|
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
|
||||||
@@ -320,13 +333,22 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
|
|
||||||
public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> {
|
public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> {
|
||||||
try {
|
try {
|
||||||
const itemResponse = await this.get<any>(
|
const endpoint =
|
||||||
`/Users/${this.userId}/Items/Latest`,
|
this.mediaServerType === MediaServerType.JELLYFIN
|
||||||
{
|
? `/Items/Latest`
|
||||||
Limit: '12',
|
: `/Users/${this.userId}/Items/Latest`;
|
||||||
ParentId: id,
|
|
||||||
}
|
const baseParams = {
|
||||||
);
|
Limit: '12',
|
||||||
|
ParentId: id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const params =
|
||||||
|
this.mediaServerType === MediaServerType.JELLYFIN
|
||||||
|
? { ...baseParams, userId: this.userId ?? `Me` }
|
||||||
|
: baseParams;
|
||||||
|
|
||||||
|
const itemResponse = await this.get<any>(endpoint, params);
|
||||||
|
|
||||||
return itemResponse;
|
return itemResponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -343,11 +365,12 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
id: string
|
id: string
|
||||||
): Promise<JellyfinLibraryItemExtended | undefined> {
|
): Promise<JellyfinLibraryItemExtended | undefined> {
|
||||||
try {
|
try {
|
||||||
const itemResponse = await this.get<any>(
|
const itemResponse = await this.get<JellyfinItemsReponse>(`/Items`, {
|
||||||
`/Users/${this.userId}/Items/${id}`
|
ids: id,
|
||||||
);
|
fields: 'ProviderIds,MediaSources,Width,Height,IsHD,DateCreated',
|
||||||
|
});
|
||||||
|
|
||||||
return itemResponse;
|
return itemResponse.Items?.[0];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (availabilitySync.running) {
|
if (availabilitySync.running) {
|
||||||
if (e.cause?.status === 500) {
|
if (e.cause?.status === 500) {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class PlexAPI {
|
|||||||
plexSettings,
|
plexSettings,
|
||||||
timeout,
|
timeout,
|
||||||
}: {
|
}: {
|
||||||
plexToken?: string;
|
plexToken?: string | null;
|
||||||
plexSettings?: PlexSettings;
|
plexSettings?: PlexSettings;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
}) {
|
}) {
|
||||||
@@ -107,7 +107,7 @@ class PlexAPI {
|
|||||||
port: settingsPlex.port,
|
port: settingsPlex.port,
|
||||||
https: settingsPlex.useSsl,
|
https: settingsPlex.useSsl,
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
token: plexToken,
|
token: plexToken ?? undefined,
|
||||||
authenticator: {
|
authenticator: {
|
||||||
authenticate: (
|
authenticate: (
|
||||||
_plexApi,
|
_plexApi,
|
||||||
@@ -124,9 +124,9 @@ class PlexAPI {
|
|||||||
// },
|
// },
|
||||||
options: {
|
options: {
|
||||||
identifier: settings.clientId,
|
identifier: settings.clientId,
|
||||||
product: 'Overseerr',
|
product: 'Jellyseerr',
|
||||||
deviceName: 'Overseerr',
|
deviceName: 'Jellyseerr',
|
||||||
platform: 'Overseerr',
|
platform: 'Jellyseerr',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -378,6 +378,7 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
logger.error('Failed to ping token', {
|
logger.error('Failed to ping token', {
|
||||||
label: 'Plex Refresh Token',
|
label: 'Plex Refresh Token',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
|
errorCause: e.cause,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
language,
|
language,
|
||||||
append_to_response:
|
append_to_response:
|
||||||
'credits,external_ids,videos,keywords,release_dates,watch/providers',
|
'credits,external_ids,videos,keywords,release_dates,watch/providers',
|
||||||
|
include_video_language: language + ', en',
|
||||||
},
|
},
|
||||||
43200
|
43200
|
||||||
);
|
);
|
||||||
@@ -280,6 +281,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
language,
|
language,
|
||||||
append_to_response:
|
append_to_response:
|
||||||
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
|
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
|
||||||
|
include_video_language: language + ', en',
|
||||||
},
|
},
|
||||||
43200
|
43200
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ export enum ApiErrorCode {
|
|||||||
NoAdminUser = 'NO_ADMIN_USER',
|
NoAdminUser = 'NO_ADMIN_USER',
|
||||||
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
|
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
|
||||||
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
|
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
|
||||||
|
Unauthorized = 'UNAUTHORIZED',
|
||||||
Unknown = 'UNKNOWN',
|
Unknown = 'UNKNOWN',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -734,8 +734,11 @@ export class MediaRequest {
|
|||||||
media.mediaType === MediaType.MOVIE &&
|
media.mediaType === MediaType.MOVIE &&
|
||||||
this.status === MediaRequestStatus.DECLINED
|
this.status === MediaRequestStatus.DECLINED
|
||||||
) {
|
) {
|
||||||
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
const statusField = this.is4k ? 'status4k' : 'status';
|
||||||
mediaRepository.save(media);
|
await mediaRepository.update(
|
||||||
|
{ id: this.media.id },
|
||||||
|
{ [statusField]: MediaStatus.UNKNOWN }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -752,8 +755,11 @@ export class MediaRequest {
|
|||||||
).length === 0 &&
|
).length === 0 &&
|
||||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING
|
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING
|
||||||
) {
|
) {
|
||||||
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
const statusField = this.is4k ? 'status4k' : 'status';
|
||||||
mediaRepository.save(media);
|
mediaRepository.update(
|
||||||
|
{ id: this.media.id },
|
||||||
|
{ [statusField]: MediaStatus.UNKNOWN }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Approve child seasons if parent is approved
|
// Approve child seasons if parent is approved
|
||||||
@@ -955,8 +961,10 @@ export class MediaRequest {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const requestRepository = getRepository(MediaRequest);
|
const requestRepository = getRepository(MediaRequest);
|
||||||
this.status = MediaRequestStatus.APPROVED;
|
|
||||||
await requestRepository.save(this);
|
await requestRepository.update(this.id, {
|
||||||
|
status: MediaRequestStatus.APPROVED,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -986,18 +994,22 @@ export class MediaRequest {
|
|||||||
throw new Error('Media data not found');
|
throw new Error('Media data not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
const updateFields = {
|
||||||
radarrMovie.id;
|
[this.is4k ? 'externalServiceId4k' : 'externalServiceId']:
|
||||||
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
radarrMovie.id,
|
||||||
radarrMovie.titleSlug;
|
[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']:
|
||||||
media[this.is4k ? 'serviceId4k' : 'serviceId'] = radarrSettings?.id;
|
radarrMovie.titleSlug,
|
||||||
await mediaRepository.save(media);
|
[this.is4k ? 'serviceId4k' : 'serviceId']: radarrMovie?.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
await mediaRepository.update({ id: this.media.id }, updateFields);
|
||||||
})
|
})
|
||||||
.catch(async () => {
|
.catch(async () => {
|
||||||
const requestRepository = getRepository(MediaRequest);
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
this.status = MediaRequestStatus.FAILED;
|
await requestRepository.update(this.id, {
|
||||||
await requestRepository.save(this);
|
status: MediaRequestStatus.FAILED,
|
||||||
|
});
|
||||||
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
||||||
@@ -1113,8 +1125,9 @@ export class MediaRequest {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const requestRepository = getRepository(MediaRequest);
|
const requestRepository = getRepository(MediaRequest);
|
||||||
this.status = MediaRequestStatus.APPROVED;
|
await requestRepository.update(this.id, {
|
||||||
await requestRepository.save(this);
|
status: MediaRequestStatus.APPROVED,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,11 +56,11 @@ export class User {
|
|||||||
})
|
})
|
||||||
public email: string;
|
public email: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ type: 'varchar', nullable: true })
|
||||||
public plexUsername?: string;
|
public plexUsername?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ type: 'varchar', nullable: true })
|
||||||
public jellyfinUsername?: string;
|
public jellyfinUsername?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public username?: string;
|
public username?: string;
|
||||||
@@ -77,20 +77,20 @@ export class User {
|
|||||||
@Column({ type: 'integer', default: UserType.PLEX })
|
@Column({ type: 'integer', default: UserType.PLEX })
|
||||||
public userType: UserType;
|
public userType: UserType;
|
||||||
|
|
||||||
@Column({ nullable: true, select: true })
|
@Column({ type: 'integer', nullable: true, select: true })
|
||||||
public plexId?: number;
|
public plexId?: number | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ type: 'varchar', nullable: true })
|
||||||
public jellyfinUserId?: string;
|
public jellyfinUserId?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true, select: false })
|
@Column({ type: 'varchar', nullable: true, select: false })
|
||||||
public jellyfinDeviceId?: string;
|
public jellyfinDeviceId?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true, select: false })
|
@Column({ type: 'varchar', nullable: true, select: false })
|
||||||
public jellyfinAuthToken?: string;
|
public jellyfinAuthToken?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true, select: false })
|
@Column({ type: 'varchar', nullable: true, select: false })
|
||||||
public plexToken?: string;
|
public plexToken?: string | null;
|
||||||
|
|
||||||
@Column({ type: 'integer', default: 0 })
|
@Column({ type: 'integer', default: 0 })
|
||||||
public permissions = 0;
|
public permissions = 0;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import csurf from '@dr.pogodin/csurf';
|
||||||
import PlexAPI from '@server/api/plexapi';
|
import PlexAPI from '@server/api/plexapi';
|
||||||
import dataSource, { getRepository, isPgsql } from '@server/datasource';
|
import dataSource, { getRepository, isPgsql } from '@server/datasource';
|
||||||
import DiscoverSlider from '@server/entity/DiscoverSlider';
|
import DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||||
@@ -28,7 +29,6 @@ import restartFlag from '@server/utils/restartFlag';
|
|||||||
import { getClientIp } from '@supercharge/request-ip';
|
import { getClientIp } from '@supercharge/request-ip';
|
||||||
import { TypeormStore } from 'connect-typeorm/out';
|
import { TypeormStore } from 'connect-typeorm/out';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import csurf from 'csurf';
|
|
||||||
import type { NextFunction, Request, Response } from 'express';
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import * as OpenApiValidator from 'express-openapi-validator';
|
import * as OpenApiValidator from 'express-openapi-validator';
|
||||||
@@ -41,9 +41,9 @@ import path from 'path';
|
|||||||
import swaggerUi from 'swagger-ui-express';
|
import swaggerUi from 'swagger-ui-express';
|
||||||
import YAML from 'yamljs';
|
import YAML from 'yamljs';
|
||||||
|
|
||||||
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
|
const API_SPEC_PATH = path.join(__dirname, '../jellyseerr-api.yml');
|
||||||
|
|
||||||
logger.info(`Starting Overseerr version ${getAppVersion()}`);
|
logger.info(`Starting Jellyseerr version ${getAppVersion()}`);
|
||||||
const dev = process.env.NODE_ENV !== 'production';
|
const dev = process.env.NODE_ENV !== 'production';
|
||||||
const app = next({ dev });
|
const app = next({ dev });
|
||||||
const handle = app.getRequestHandler();
|
const handle = app.getRequestHandler();
|
||||||
@@ -83,12 +83,6 @@ app
|
|||||||
net.setDefaultAutoSelectFamily(false);
|
net.setDefaultAutoSelectFamily(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.network.dnsServers.trim() !== '') {
|
|
||||||
dns.setServers(
|
|
||||||
settings.network.dnsServers.split(',').map((server) => server.trim())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface PublicSettingsResponse {
|
|||||||
applicationUrl: string;
|
applicationUrl: string;
|
||||||
hideAvailable: boolean;
|
hideAvailable: boolean;
|
||||||
localLogin: boolean;
|
localLogin: boolean;
|
||||||
|
mediaServerLogin: boolean;
|
||||||
movie4kEnabled: boolean;
|
movie4kEnabled: boolean;
|
||||||
series4kEnabled: boolean;
|
series4kEnabled: boolean;
|
||||||
discoverRegion: string;
|
discoverRegion: string;
|
||||||
|
|||||||
@@ -404,6 +404,34 @@ class AvailabilitySync {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!showExists &&
|
||||||
|
(media.status === MediaStatus.AVAILABLE ||
|
||||||
|
media.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||||
|
media.seasons.some(
|
||||||
|
(season) => season.status === MediaStatus.AVAILABLE
|
||||||
|
) ||
|
||||||
|
media.seasons.some(
|
||||||
|
(season) => season.status === MediaStatus.PARTIALLY_AVAILABLE
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
await this.mediaUpdater(media, false, mediaServerType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!showExists4k &&
|
||||||
|
(media.status4k === MediaStatus.AVAILABLE ||
|
||||||
|
media.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||||
|
media.seasons.some(
|
||||||
|
(season) => season.status4k === MediaStatus.AVAILABLE
|
||||||
|
) ||
|
||||||
|
media.seasons.some(
|
||||||
|
(season) => season.status4k === MediaStatus.PARTIALLY_AVAILABLE
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
await this.mediaUpdater(media, true, mediaServerType);
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Figure out how to run seasonUpdater for each season
|
// TODO: Figure out how to run seasonUpdater for each season
|
||||||
|
|
||||||
if ([...finalSeasons.values()].includes(false)) {
|
if ([...finalSeasons.values()].includes(false)) {
|
||||||
@@ -423,22 +451,6 @@ class AvailabilitySync {
|
|||||||
mediaServerType
|
mediaServerType
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
!showExists &&
|
|
||||||
(media.status === MediaStatus.AVAILABLE ||
|
|
||||||
media.status === MediaStatus.PARTIALLY_AVAILABLE)
|
|
||||||
) {
|
|
||||||
await this.mediaUpdater(media, false, mediaServerType);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!showExists4k &&
|
|
||||||
(media.status4k === MediaStatus.AVAILABLE ||
|
|
||||||
media.status4k === MediaStatus.PARTIALLY_AVAILABLE)
|
|
||||||
) {
|
|
||||||
await this.mediaUpdater(media, true, mediaServerType);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
@@ -466,6 +478,10 @@ class AvailabilitySync {
|
|||||||
{ status: MediaStatus.PARTIALLY_AVAILABLE },
|
{ status: MediaStatus.PARTIALLY_AVAILABLE },
|
||||||
{ status4k: MediaStatus.AVAILABLE },
|
{ status4k: MediaStatus.AVAILABLE },
|
||||||
{ status4k: MediaStatus.PARTIALLY_AVAILABLE },
|
{ status4k: MediaStatus.PARTIALLY_AVAILABLE },
|
||||||
|
{ seasons: { status: MediaStatus.AVAILABLE } },
|
||||||
|
{ seasons: { status: MediaStatus.PARTIALLY_AVAILABLE } },
|
||||||
|
{ seasons: { status4k: MediaStatus.AVAILABLE } },
|
||||||
|
{ seasons: { status4k: MediaStatus.PARTIALLY_AVAILABLE } },
|
||||||
];
|
];
|
||||||
|
|
||||||
let mediaPage: Media[];
|
let mediaPage: Media[];
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class PreparedEmail extends Email {
|
|||||||
},
|
},
|
||||||
send: true,
|
send: true,
|
||||||
transport: transport,
|
transport: transport,
|
||||||
|
preview: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface NotificationPayload {
|
|||||||
request?: MediaRequest;
|
request?: MediaRequest;
|
||||||
issue?: Issue;
|
issue?: Issue;
|
||||||
comment?: IssueComment;
|
comment?: IssueComment;
|
||||||
|
pendingRequestsCount?: number;
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class BaseAgent<T extends NotificationAgentConfig> {
|
export abstract class BaseAgent<T extends NotificationAgentConfig> {
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ class SlackAgent
|
|||||||
type: 'actions',
|
type: 'actions',
|
||||||
elements: [
|
elements: [
|
||||||
{
|
{
|
||||||
action_id: 'open-in-overseerr',
|
action_id: 'open-in-jellyseerr',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
url,
|
url,
|
||||||
text: {
|
text: {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { IssueType, IssueTypeName } from '@server/constants/issue';
|
import { IssueType, IssueTypeName } from '@server/constants/issue';
|
||||||
import { MediaType } from '@server/constants/media';
|
import { MediaRequestStatus, MediaType } from '@server/constants/media';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
|
import MediaRequest from '@server/entity/MediaRequest';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
|
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
|
||||||
import type { NotificationAgentConfig } from '@server/lib/settings';
|
import type { NotificationAgentConfig } from '@server/lib/settings';
|
||||||
@@ -19,6 +20,8 @@ interface PushNotificationPayload {
|
|||||||
actionUrl?: string;
|
actionUrl?: string;
|
||||||
actionUrlTitle?: string;
|
actionUrlTitle?: string;
|
||||||
requestId?: number;
|
requestId?: number;
|
||||||
|
pendingRequestsCount?: number;
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class WebPushAgent
|
class WebPushAgent
|
||||||
@@ -129,6 +132,8 @@ class WebPushAgent
|
|||||||
requestId: payload.request?.id,
|
requestId: payload.request?.id,
|
||||||
actionUrl,
|
actionUrl,
|
||||||
actionUrlTitle,
|
actionUrlTitle,
|
||||||
|
pendingRequestsCount: payload.pendingRequestsCount,
|
||||||
|
isAdmin: payload.isAdmin,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +157,51 @@ class WebPushAgent
|
|||||||
|
|
||||||
const mainUser = await userRepository.findOne({ where: { id: 1 } });
|
const mainUser = await userRepository.findOne({ where: { id: 1 } });
|
||||||
|
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
|
const pendingRequests = await requestRepository.find({
|
||||||
|
where: { status: MediaRequestStatus.PENDING },
|
||||||
|
});
|
||||||
|
|
||||||
|
const webPushNotification = async (
|
||||||
|
pushSub: UserPushSubscription,
|
||||||
|
notificationPayload: Buffer
|
||||||
|
) => {
|
||||||
|
logger.debug('Sending web push notification', {
|
||||||
|
label: 'Notifications',
|
||||||
|
recipient: pushSub.user.displayName,
|
||||||
|
type: Notification[type],
|
||||||
|
subject: payload.subject,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await webpush.sendNotification(
|
||||||
|
{
|
||||||
|
endpoint: pushSub.endpoint,
|
||||||
|
keys: {
|
||||||
|
auth: pushSub.auth,
|
||||||
|
p256dh: pushSub.p256dh,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
notificationPayload
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
'Error sending web push notification; removing subscription',
|
||||||
|
{
|
||||||
|
label: 'Notifications',
|
||||||
|
recipient: pushSub.user.displayName,
|
||||||
|
type: Notification[type],
|
||||||
|
subject: payload.subject,
|
||||||
|
errorMessage: e.message,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Failed to send notification so we need to remove the subscription
|
||||||
|
userPushSubRepository.remove(pushSub);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
payload.notifyUser &&
|
payload.notifyUser &&
|
||||||
// Check if user has webpush notifications enabled and fallback to true if undefined
|
// Check if user has webpush notifications enabled and fallback to true if undefined
|
||||||
@@ -169,7 +219,11 @@ class WebPushAgent
|
|||||||
pushSubs.push(...notifySubs);
|
pushSubs.push(...notifySubs);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.notifyAdmin) {
|
if (
|
||||||
|
payload.notifyAdmin ||
|
||||||
|
type === Notification.MEDIA_APPROVED ||
|
||||||
|
type === Notification.MEDIA_DECLINED
|
||||||
|
) {
|
||||||
const users = await userRepository.find();
|
const users = await userRepository.find();
|
||||||
|
|
||||||
const manageUsers = users.filter(
|
const manageUsers = users.filter(
|
||||||
@@ -192,7 +246,42 @@ class WebPushAgent
|
|||||||
})
|
})
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|
||||||
pushSubs.push(...allSubs);
|
// We only want to send the custom notification when type is approved or declined
|
||||||
|
// Otherwise, default to the normal notification
|
||||||
|
if (
|
||||||
|
type === Notification.MEDIA_APPROVED ||
|
||||||
|
type === Notification.MEDIA_DECLINED
|
||||||
|
) {
|
||||||
|
if (mainUser && allSubs.length > 0) {
|
||||||
|
webpush.setVapidDetails(
|
||||||
|
`mailto:${mainUser.email}`,
|
||||||
|
settings.vapidPublic,
|
||||||
|
settings.vapidPrivate
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom payload only for updating the app badge
|
||||||
|
const notificationBadgePayload = Buffer.from(
|
||||||
|
JSON.stringify(
|
||||||
|
this.getNotificationPayload(type, {
|
||||||
|
subject: payload.subject,
|
||||||
|
notifySystem: false,
|
||||||
|
notifyAdmin: true,
|
||||||
|
isAdmin: true,
|
||||||
|
pendingRequestsCount: pendingRequests.length,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
allSubs.map(async (sub) => {
|
||||||
|
webPushNotification(sub, notificationBadgePayload);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pushSubs.push(...allSubs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mainUser && pushSubs.length > 0) {
|
if (mainUser && pushSubs.length > 0) {
|
||||||
@@ -202,6 +291,10 @@ class WebPushAgent
|
|||||||
settings.vapidPrivate
|
settings.vapidPrivate
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (type === Notification.MEDIA_PENDING) {
|
||||||
|
payload = { ...payload, pendingRequestsCount: pendingRequests.length };
|
||||||
|
}
|
||||||
|
|
||||||
const notificationPayload = Buffer.from(
|
const notificationPayload = Buffer.from(
|
||||||
JSON.stringify(this.getNotificationPayload(type, payload)),
|
JSON.stringify(this.getNotificationPayload(type, payload)),
|
||||||
'utf-8'
|
'utf-8'
|
||||||
@@ -209,39 +302,7 @@ class WebPushAgent
|
|||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
pushSubs.map(async (sub) => {
|
pushSubs.map(async (sub) => {
|
||||||
logger.debug('Sending web push notification', {
|
webPushNotification(sub, notificationPayload);
|
||||||
label: 'Notifications',
|
|
||||||
recipient: sub.user.displayName,
|
|
||||||
type: Notification[type],
|
|
||||||
subject: payload.subject,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await webpush.sendNotification(
|
|
||||||
{
|
|
||||||
endpoint: sub.endpoint,
|
|
||||||
keys: {
|
|
||||||
auth: sub.auth,
|
|
||||||
p256dh: sub.p256dh,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
notificationPayload
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
logger.error(
|
|
||||||
'Error sending web push notification; removing subscription',
|
|
||||||
{
|
|
||||||
label: 'Notifications',
|
|
||||||
recipient: sub.user.displayName,
|
|
||||||
type: Notification[type],
|
|
||||||
subject: payload.subject,
|
|
||||||
errorMessage: e.message,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Failed to send notification so we need to remove the subscription
|
|
||||||
userPushSubRepository.remove(sub);
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export interface MainSettings {
|
|||||||
};
|
};
|
||||||
hideAvailable: boolean;
|
hideAvailable: boolean;
|
||||||
localLogin: boolean;
|
localLogin: boolean;
|
||||||
|
mediaServerLogin: boolean;
|
||||||
newPlexLogin: boolean;
|
newPlexLogin: boolean;
|
||||||
discoverRegion: string;
|
discoverRegion: string;
|
||||||
streamingRegion: string;
|
streamingRegion: string;
|
||||||
@@ -136,7 +137,6 @@ export interface MainSettings {
|
|||||||
export interface NetworkSettings {
|
export interface NetworkSettings {
|
||||||
csrfProtection: boolean;
|
csrfProtection: boolean;
|
||||||
forceIpv4First: boolean;
|
forceIpv4First: boolean;
|
||||||
dnsServers: string;
|
|
||||||
trustProxy: boolean;
|
trustProxy: boolean;
|
||||||
proxy: ProxySettings;
|
proxy: ProxySettings;
|
||||||
}
|
}
|
||||||
@@ -150,6 +150,7 @@ interface FullPublicSettings extends PublicSettings {
|
|||||||
applicationUrl: string;
|
applicationUrl: string;
|
||||||
hideAvailable: boolean;
|
hideAvailable: boolean;
|
||||||
localLogin: boolean;
|
localLogin: boolean;
|
||||||
|
mediaServerLogin: boolean;
|
||||||
movie4kEnabled: boolean;
|
movie4kEnabled: boolean;
|
||||||
series4kEnabled: boolean;
|
series4kEnabled: boolean;
|
||||||
discoverRegion: string;
|
discoverRegion: string;
|
||||||
@@ -343,6 +344,7 @@ class Settings {
|
|||||||
},
|
},
|
||||||
hideAvailable: false,
|
hideAvailable: false,
|
||||||
localLogin: true,
|
localLogin: true,
|
||||||
|
mediaServerLogin: true,
|
||||||
newPlexLogin: true,
|
newPlexLogin: true,
|
||||||
discoverRegion: '',
|
discoverRegion: '',
|
||||||
streamingRegion: '',
|
streamingRegion: '',
|
||||||
@@ -507,7 +509,6 @@ class Settings {
|
|||||||
csrfProtection: false,
|
csrfProtection: false,
|
||||||
trustProxy: false,
|
trustProxy: false,
|
||||||
forceIpv4First: false,
|
forceIpv4First: false,
|
||||||
dnsServers: '',
|
|
||||||
proxy: {
|
proxy: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
hostname: '',
|
hostname: '',
|
||||||
@@ -588,6 +589,8 @@ class Settings {
|
|||||||
applicationUrl: this.data.main.applicationUrl,
|
applicationUrl: this.data.main.applicationUrl,
|
||||||
hideAvailable: this.data.main.hideAvailable,
|
hideAvailable: this.data.main.hideAvailable,
|
||||||
localLogin: this.data.main.localLogin,
|
localLogin: this.data.main.localLogin,
|
||||||
|
mediaServerLogin: this.data.main.mediaServerLogin,
|
||||||
|
jellyfinExternalHost: this.data.jellyfin.externalHostname,
|
||||||
jellyfinForgotPasswordUrl: this.data.jellyfin.jellyfinForgotPasswordUrl,
|
jellyfinForgotPasswordUrl: this.data.jellyfin.jellyfinForgotPasswordUrl,
|
||||||
movie4kEnabled: this.data.radarr.some(
|
movie4kEnabled: this.data.radarr.some(
|
||||||
(radarr) => radarr.is4k && radarr.isDefault
|
(radarr) => radarr.is4k && radarr.isDefault
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ const migrateNetworkSettings = (settings: any): AllSettings => {
|
|||||||
csrfProtection: settings.main.csrfProtection ?? false,
|
csrfProtection: settings.main.csrfProtection ?? false,
|
||||||
trustProxy: settings.main.trustProxy ?? false,
|
trustProxy: settings.main.trustProxy ?? false,
|
||||||
forceIpv4First: settings.main.forceIpv4First ?? false,
|
forceIpv4First: settings.main.forceIpv4First ?? false,
|
||||||
dnsServers: settings.main.dnsServers ?? '',
|
|
||||||
proxy: settings.main.proxy ?? {
|
proxy: settings.main.proxy ?? {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
hostname: '',
|
hostname: '',
|
||||||
@@ -25,7 +24,6 @@ const migrateNetworkSettings = (settings: any): AllSettings => {
|
|||||||
delete settings.main.csrfProtection;
|
delete settings.main.csrfProtection;
|
||||||
delete settings.main.trustProxy;
|
delete settings.main.trustProxy;
|
||||||
delete settings.main.forceIpv4First;
|
delete settings.main.forceIpv4First;
|
||||||
delete settings.main.dnsServers;
|
|
||||||
delete settings.main.proxy;
|
delete settings.main.proxy;
|
||||||
return newSettings;
|
return newSettings;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ class WatchlistSync {
|
|||||||
|
|
||||||
switch (e.constructor) {
|
switch (e.constructor) {
|
||||||
// During watchlist sync, these errors aren't necessarily
|
// During watchlist sync, these errors aren't necessarily
|
||||||
// a problem with Overseerr. Since we are auto syncing these constantly, it's
|
// a problem with Jellyseerr. Since we are auto syncing these constantly, it's
|
||||||
// possible they are unexpectedly at their quota limit, for example. So we'll
|
// possible they are unexpectedly at their quota limit, for example. So we'll
|
||||||
// instead log these as debug messages.
|
// instead log these as debug messages.
|
||||||
case RequestPermissionError:
|
case RequestPermissionError:
|
||||||
|
|||||||
@@ -43,14 +43,14 @@ const logger = winston.createLogger({
|
|||||||
}),
|
}),
|
||||||
new winston.transports.DailyRotateFile({
|
new winston.transports.DailyRotateFile({
|
||||||
filename: process.env.CONFIG_DIRECTORY
|
filename: process.env.CONFIG_DIRECTORY
|
||||||
? `${process.env.CONFIG_DIRECTORY}/logs/overseerr-%DATE%.log`
|
? `${process.env.CONFIG_DIRECTORY}/logs/jellyseerr-%DATE%.log`
|
||||||
: path.join(__dirname, '../config/logs/overseerr-%DATE%.log'),
|
: path.join(__dirname, '../config/logs/jellyseerr-%DATE%.log'),
|
||||||
datePattern: 'YYYY-MM-DD',
|
datePattern: 'YYYY-MM-DD',
|
||||||
zippedArchive: true,
|
zippedArchive: true,
|
||||||
maxSize: '20m',
|
maxSize: '20m',
|
||||||
maxFiles: '7d',
|
maxFiles: '7d',
|
||||||
createSymlink: true,
|
createSymlink: true,
|
||||||
symlinkName: 'overseerr.log',
|
symlinkName: 'jellyseerr.log',
|
||||||
}),
|
}),
|
||||||
new winston.transports.DailyRotateFile({
|
new winston.transports.DailyRotateFile({
|
||||||
filename: process.env.CONFIG_DIRECTORY
|
filename: process.env.CONFIG_DIRECTORY
|
||||||
|
|||||||
@@ -56,8 +56,9 @@ authRoutes.post('/plex', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
settings.main.mediaServerType != MediaServerType.PLEX &&
|
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED &&
|
||||||
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED
|
(settings.main.mediaServerLogin === false ||
|
||||||
|
settings.main.mediaServerType != MediaServerType.PLEX)
|
||||||
) {
|
) {
|
||||||
return res.status(500).json({ error: 'Plex login is disabled' });
|
return res.status(500).json({ error: 'Plex login is disabled' });
|
||||||
}
|
}
|
||||||
@@ -157,7 +158,7 @@ authRoutes.post('/plex', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.info(
|
logger.info(
|
||||||
'Sign-in attempt from Plex user with access to the media server; creating new Overseerr user',
|
'Sign-in attempt from Plex user with access to the media server; creating new Jellyseerr user',
|
||||||
{
|
{
|
||||||
label: 'API',
|
label: 'API',
|
||||||
ip: req.ip,
|
ip: req.ip,
|
||||||
@@ -231,10 +232,13 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
|
|
||||||
//Make sure jellyfin login is enabled, but only if jellyfin && Emby is not already configured
|
//Make sure jellyfin login is enabled, but only if jellyfin && Emby is not already configured
|
||||||
if (
|
if (
|
||||||
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
// media server not configured, allow login for setup
|
||||||
settings.main.mediaServerType !== MediaServerType.EMBY &&
|
|
||||||
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED &&
|
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED &&
|
||||||
settings.jellyfin.ip !== ''
|
(settings.main.mediaServerLogin === false ||
|
||||||
|
// media server is neither jellyfin or emby
|
||||||
|
(settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
||||||
|
settings.main.mediaServerType !== MediaServerType.EMBY &&
|
||||||
|
settings.jellyfin.ip !== ''))
|
||||||
) {
|
) {
|
||||||
return res.status(500).json({ error: 'Jellyfin login is disabled' });
|
return res.status(500).json({ error: 'Jellyfin login is disabled' });
|
||||||
}
|
}
|
||||||
@@ -270,7 +274,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
if (user) {
|
if (user) {
|
||||||
deviceId = user.jellyfinDeviceId ?? '';
|
deviceId = user.jellyfinDeviceId ?? '';
|
||||||
} else {
|
} else {
|
||||||
deviceId = Buffer.from(`BOT_overseerr_${body.username ?? ''}`).toString(
|
deviceId = Buffer.from(`BOT_jellyseerr_${body.username ?? ''}`).toString(
|
||||||
'base64'
|
'base64'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -442,7 +446,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
} else if (!user) {
|
} else if (!user) {
|
||||||
logger.info(
|
logger.info(
|
||||||
'Sign-in attempt from Jellyfin user with access to the media server; creating new Overseerr user',
|
'Sign-in attempt from Jellyfin user with access to the media server; creating new Jellyseerr user',
|
||||||
{
|
{
|
||||||
label: 'API',
|
label: 'API',
|
||||||
ip: req.ip,
|
ip: req.ip,
|
||||||
@@ -580,7 +584,7 @@ authRoutes.post('/local', async (req, res, next) => {
|
|||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
if (!user || !(await user.passwordMatch(body.password))) {
|
if (!user || !(await user.passwordMatch(body.password))) {
|
||||||
logger.warn('Failed sign-in attempt using invalid Overseerr password', {
|
logger.warn('Failed sign-in attempt using invalid Jellyseerr password', {
|
||||||
label: 'API',
|
label: 'API',
|
||||||
ip: req.ip,
|
ip: req.ip,
|
||||||
email: body.email,
|
email: body.email,
|
||||||
@@ -670,7 +674,7 @@ authRoutes.post('/local', async (req, res, next) => {
|
|||||||
return res.status(200).json(user?.filter() ?? {});
|
return res.status(200).json(user?.filter() ?? {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong authenticating with Overseerr password',
|
'Something went wrong authenticating with Jellyseerr password',
|
||||||
{
|
{
|
||||||
label: 'API',
|
label: 'API',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
|
|||||||
@@ -837,7 +837,8 @@ discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
|
|||||||
select: ['id', 'plexToken'],
|
select: ['id', 'plexToken'],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (activeUser) {
|
if (activeUser && !activeUser?.plexToken) {
|
||||||
|
// Non-Plex users can only see their own watchlist
|
||||||
const [result, total] = await getRepository(Watchlist).findAndCount({
|
const [result, total] = await getRepository(Watchlist).findAndCount({
|
||||||
where: { requestedBy: { id: activeUser?.id } },
|
where: { requestedBy: { id: activeUser?.id } },
|
||||||
relations: {
|
relations: {
|
||||||
@@ -866,6 +867,7 @@ discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List watchlist from Plex
|
||||||
const plexTV = new PlexTvAPI(activeUser.plexToken);
|
const plexTV = new PlexTvAPI(activeUser.plexToken);
|
||||||
|
|
||||||
const watchlist = await plexTV.getWatchlist({ offset });
|
const watchlist = await plexTV.getWatchlist({ offset });
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ router.get<unknown, StatusResponse>('/status', async (req, res) => {
|
|||||||
let commitsBehind = 0;
|
let commitsBehind = 0;
|
||||||
|
|
||||||
if (currentVersion.startsWith('develop-') && commitTag !== 'local') {
|
if (currentVersion.startsWith('develop-') && commitTag !== 'local') {
|
||||||
const commits = await githubApi.getOverseerrCommits();
|
const commits = await githubApi.getJellyseerrCommits();
|
||||||
|
|
||||||
if (commits.length) {
|
if (commits.length) {
|
||||||
const filteredCommits = commits.filter(
|
const filteredCommits = commits.filter(
|
||||||
@@ -74,7 +74,7 @@ router.get<unknown, StatusResponse>('/status', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (commitTag !== 'local') {
|
} else if (commitTag !== 'local') {
|
||||||
const releases = await githubApi.getOverseerrReleases();
|
const releases = await githubApi.getJellyseerrReleases();
|
||||||
|
|
||||||
if (releases.length) {
|
if (releases.length) {
|
||||||
const latestVersion = releases[0];
|
const latestVersion = releases[0];
|
||||||
@@ -403,7 +403,7 @@ router.get('/watchproviders/tv', async (req, res, next) => {
|
|||||||
|
|
||||||
router.get('/', (_req, res) => {
|
router.get('/', (_req, res) => {
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
api: 'Overseerr API',
|
api: 'Jellyseerr API',
|
||||||
version: '1.0',
|
version: '1.0',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -237,6 +237,19 @@ mediaRoutes.delete(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isMovie) {
|
if (isMovie) {
|
||||||
|
// check if the movie exists
|
||||||
|
try {
|
||||||
|
await (service as RadarrAPI).getMovie({
|
||||||
|
id: parseInt(
|
||||||
|
is4k
|
||||||
|
? (media.externalServiceSlug4k as string)
|
||||||
|
: (media.externalServiceSlug as string)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return res.status(204).send();
|
||||||
|
}
|
||||||
|
// remove the movie
|
||||||
await (service as RadarrAPI).removeMovie(
|
await (service as RadarrAPI).removeMovie(
|
||||||
parseInt(
|
parseInt(
|
||||||
is4k
|
is4k
|
||||||
@@ -251,6 +264,13 @@ mediaRoutes.delete(
|
|||||||
if (!tvdbId) {
|
if (!tvdbId) {
|
||||||
throw new Error('TVDB ID not found');
|
throw new Error('TVDB ID not found');
|
||||||
}
|
}
|
||||||
|
// check if the series exists
|
||||||
|
try {
|
||||||
|
await (service as SonarrAPI).getSeriesByTvdbId(tvdbId);
|
||||||
|
} catch {
|
||||||
|
return res.status(204).send();
|
||||||
|
}
|
||||||
|
// remove the series
|
||||||
await (service as SonarrAPI).removeSerie(tvdbId);
|
await (service as SonarrAPI).removeSerie(tvdbId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
|
|||||||
const account = await jellyfinClient.getUser();
|
const account = await jellyfinClient.getUser();
|
||||||
|
|
||||||
// Automatic Library grouping is not supported when user views are used to get library
|
// Automatic Library grouping is not supported when user views are used to get library
|
||||||
if (account.Configuration.GroupedFolders.length > 0) {
|
if (account.Configuration.GroupedFolders?.length > 0) {
|
||||||
return next({
|
return next({
|
||||||
status: 501,
|
status: 501,
|
||||||
message: ApiErrorCode.SyncErrorGroupedFolders,
|
message: ApiErrorCode.SyncErrorGroupedFolders,
|
||||||
|
|||||||
@@ -32,7 +32,14 @@ const router = Router();
|
|||||||
|
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const pageSize = req.query.take ? Number(req.query.take) : 10;
|
const includeIds = [
|
||||||
|
...new Set(
|
||||||
|
req.query.includeIds ? req.query.includeIds.toString().split(',') : []
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const pageSize = req.query.take
|
||||||
|
? Number(req.query.take)
|
||||||
|
: Math.max(10, includeIds.length);
|
||||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||||
const q = req.query.q ? req.query.q.toString().toLowerCase() : '';
|
const q = req.query.q ? req.query.q.toString().toLowerCase() : '';
|
||||||
let query = getRepository(User).createQueryBuilder('user');
|
let query = getRepository(User).createQueryBuilder('user');
|
||||||
@@ -44,27 +51,33 @@ router.get('/', async (req, res, next) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (includeIds.length > 0) {
|
||||||
|
query.andWhereInIds(includeIds);
|
||||||
|
}
|
||||||
|
|
||||||
switch (req.query.sort) {
|
switch (req.query.sort) {
|
||||||
case 'updated':
|
case 'updated':
|
||||||
query = query.orderBy('user.updatedAt', 'DESC');
|
query = query.orderBy('user.updatedAt', 'DESC');
|
||||||
break;
|
break;
|
||||||
case 'displayname':
|
case 'displayname':
|
||||||
query = query.orderBy(
|
query = query
|
||||||
`CASE WHEN (user.username IS NULL OR user.username = '') THEN (
|
.addSelect(
|
||||||
CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN (
|
`CASE WHEN (user.username IS NULL OR user.username = '') THEN (
|
||||||
CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN
|
CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN (
|
||||||
"user"."email"
|
CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN
|
||||||
ELSE
|
"user"."email"
|
||||||
LOWER(user.jellyfinUsername)
|
ELSE
|
||||||
END)
|
LOWER(user.jellyfinUsername)
|
||||||
ELSE
|
END)
|
||||||
LOWER(user.jellyfinUsername)
|
ELSE
|
||||||
END)
|
LOWER(user.jellyfinUsername)
|
||||||
ELSE
|
END)
|
||||||
LOWER(user.username)
|
ELSE
|
||||||
END`,
|
LOWER(user.username)
|
||||||
'ASC'
|
END`,
|
||||||
);
|
'displayname_sort_key'
|
||||||
|
)
|
||||||
|
.orderBy('displayname_sort_key', 'ASC');
|
||||||
break;
|
break;
|
||||||
case 'requests':
|
case 'requests':
|
||||||
query = query
|
query = query
|
||||||
@@ -84,6 +97,7 @@ router.get('/', async (req, res, next) => {
|
|||||||
const [users, userCount] = await query
|
const [users, userCount] = await query
|
||||||
.take(pageSize)
|
.take(pageSize)
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
|
.distinct(true)
|
||||||
.getManyAndCount();
|
.getManyAndCount();
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import JellyfinAPI from '@server/api/jellyfin';
|
||||||
|
import PlexTvAPI from '@server/api/plextv';
|
||||||
import { ApiErrorCode } from '@server/constants/error';
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import { UserType } from '@server/constants/user';
|
import { UserType } from '@server/constants/user';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
@@ -12,9 +15,23 @@ import { getSettings } from '@server/lib/settings';
|
|||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { isAuthenticated } from '@server/middleware/auth';
|
import { isAuthenticated } from '@server/middleware/auth';
|
||||||
import { ApiError } from '@server/types/error';
|
import { ApiError } from '@server/types/error';
|
||||||
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import net from 'net';
|
||||||
import { canMakePermissionsChange } from '.';
|
import { canMakePermissionsChange } from '.';
|
||||||
|
|
||||||
|
const isOwnProfile = (): Middleware => {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (req.user?.id !== Number(req.params.id)) {
|
||||||
|
return next({
|
||||||
|
status: 403,
|
||||||
|
message: "You do not have permission to view this user's settings.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const isOwnProfileOrAdmin = (): Middleware => {
|
const isOwnProfileOrAdmin = (): Middleware => {
|
||||||
const authMiddleware: Middleware = (req, res, next) => {
|
const authMiddleware: Middleware = (req, res, next) => {
|
||||||
if (
|
if (
|
||||||
@@ -102,28 +119,10 @@ userSettingsRoutes.post<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const oldEmail = user.email;
|
const oldEmail = user.email;
|
||||||
const oldUsername = user.username;
|
|
||||||
user.username = req.body.username;
|
user.username = req.body.username;
|
||||||
if (user.jellyfinUsername) {
|
if (user.userType !== UserType.PLEX) {
|
||||||
user.email = req.body.email || user.jellyfinUsername || user.email;
|
user.email = req.body.email || user.jellyfinUsername || user.email;
|
||||||
}
|
}
|
||||||
// Edge case for local users, because they have no Jellyfin username to fall back on
|
|
||||||
// if the email is not provided
|
|
||||||
if (user.userType === UserType.LOCAL) {
|
|
||||||
if (req.body.email) {
|
|
||||||
user.email = req.body.email;
|
|
||||||
if (
|
|
||||||
!user.username &&
|
|
||||||
user.email !== oldEmail &&
|
|
||||||
!oldEmail.includes('@')
|
|
||||||
) {
|
|
||||||
user.username = oldEmail;
|
|
||||||
}
|
|
||||||
} else if (req.body.username) {
|
|
||||||
user.email = oldUsername || user.email;
|
|
||||||
user.username = req.body.username;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingUser = await userRepository.findOne({
|
const existingUser = await userRepository.findOne({
|
||||||
where: { email: user.email },
|
where: { email: user.email },
|
||||||
@@ -183,9 +182,8 @@ userSettingsRoutes.post<
|
|||||||
status: e.statusCode,
|
status: e.statusCode,
|
||||||
message: e.errorCode,
|
message: e.errorCode,
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
return next({ status: 500, message: e.message });
|
|
||||||
}
|
}
|
||||||
|
return next({ status: 500, message: e.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -290,6 +288,260 @@ userSettingsRoutes.post<
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
userSettingsRoutes.post<{ authToken: string }>(
|
||||||
|
'/linked-accounts/plex',
|
||||||
|
isOwnProfile(),
|
||||||
|
async (req, res) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
const userRepository = getRepository(User);
|
||||||
|
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(404).json({ code: ApiErrorCode.Unauthorized });
|
||||||
|
}
|
||||||
|
// Make sure Plex login is enabled
|
||||||
|
if (settings.main.mediaServerType !== MediaServerType.PLEX) {
|
||||||
|
return res.status(500).json({ message: 'Plex login is disabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// First we need to use this auth token to get the user's email from plex.tv
|
||||||
|
const plextv = new PlexTvAPI(req.body.authToken);
|
||||||
|
const account = await plextv.getUser();
|
||||||
|
|
||||||
|
// Do not allow linking of an already linked account
|
||||||
|
if (await userRepository.exist({ where: { plexId: account.id } })) {
|
||||||
|
return res.status(422).json({
|
||||||
|
message: 'This Plex account is already linked to a Jellyseerr user',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = req.user;
|
||||||
|
|
||||||
|
// Emails do not match
|
||||||
|
if (user.email !== account.email) {
|
||||||
|
return res.status(422).json({
|
||||||
|
message:
|
||||||
|
'This Plex account is registered under a different email address.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// valid plex user found, link to current user
|
||||||
|
user.userType = UserType.PLEX;
|
||||||
|
user.plexId = account.id;
|
||||||
|
user.plexUsername = account.username;
|
||||||
|
user.plexToken = account.authToken;
|
||||||
|
await userRepository.save(user);
|
||||||
|
|
||||||
|
return res.status(204).send();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
userSettingsRoutes.delete<{ id: string }>(
|
||||||
|
'/linked-accounts/plex',
|
||||||
|
isOwnProfileOrAdmin(),
|
||||||
|
async (req, res) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
const userRepository = getRepository(User);
|
||||||
|
|
||||||
|
// Make sure Plex login is enabled
|
||||||
|
if (settings.main.mediaServerType !== MediaServerType.PLEX) {
|
||||||
|
return res.status(500).json({ message: 'Plex login is disabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await userRepository
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.addSelect('user.password')
|
||||||
|
.where({
|
||||||
|
id: Number(req.params.id),
|
||||||
|
})
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ message: 'User not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.id === 1) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message:
|
||||||
|
'Cannot unlink media server accounts for the primary administrator.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.email || !user.password) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: 'User does not have a local email or password set.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
user.userType = UserType.LOCAL;
|
||||||
|
user.plexId = null;
|
||||||
|
user.plexUsername = null;
|
||||||
|
user.plexToken = null;
|
||||||
|
await userRepository.save(user);
|
||||||
|
|
||||||
|
return res.status(204).send();
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ message: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
userSettingsRoutes.post<{ username: string; password: string }>(
|
||||||
|
'/linked-accounts/jellyfin',
|
||||||
|
isOwnProfile(),
|
||||||
|
async (req, res) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
const userRepository = getRepository(User);
|
||||||
|
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ code: ApiErrorCode.Unauthorized });
|
||||||
|
}
|
||||||
|
// Make sure jellyfin login is enabled
|
||||||
|
if (
|
||||||
|
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
||||||
|
settings.main.mediaServerType !== MediaServerType.EMBY
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ message: 'Jellyfin/Emby login is disabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not allow linking of an already linked account
|
||||||
|
if (
|
||||||
|
await userRepository.exist({
|
||||||
|
where: { jellyfinUsername: req.body.username },
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return res.status(422).json({
|
||||||
|
message: 'The specified account is already linked to a Jellyseerr user',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = getHostname();
|
||||||
|
const deviceId = Buffer.from(
|
||||||
|
`BOT_jellyseerr_${req.user.username ?? ''}`
|
||||||
|
).toString('base64');
|
||||||
|
|
||||||
|
const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId);
|
||||||
|
|
||||||
|
const ip = req.ip;
|
||||||
|
let clientIp: string | undefined;
|
||||||
|
if (ip) {
|
||||||
|
if (net.isIPv4(ip)) {
|
||||||
|
clientIp = ip;
|
||||||
|
} else if (net.isIPv6(ip)) {
|
||||||
|
clientIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const account = await jellyfinserver.login(
|
||||||
|
req.body.username,
|
||||||
|
req.body.password,
|
||||||
|
clientIp
|
||||||
|
);
|
||||||
|
|
||||||
|
// Do not allow linking of an already linked account
|
||||||
|
if (
|
||||||
|
await userRepository.exist({
|
||||||
|
where: { jellyfinUserId: account.User.Id },
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return res.status(422).json({
|
||||||
|
message:
|
||||||
|
'The specified account is already linked to a Jellyseerr user',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = req.user;
|
||||||
|
|
||||||
|
// valid jellyfin user found, link to current user
|
||||||
|
user.userType =
|
||||||
|
settings.main.mediaServerType === MediaServerType.EMBY
|
||||||
|
? UserType.EMBY
|
||||||
|
: UserType.JELLYFIN;
|
||||||
|
user.jellyfinUserId = account.User.Id;
|
||||||
|
user.jellyfinUsername = account.User.Name;
|
||||||
|
user.jellyfinAuthToken = account.AccessToken;
|
||||||
|
user.jellyfinDeviceId = deviceId;
|
||||||
|
await userRepository.save(user);
|
||||||
|
|
||||||
|
return res.status(204).send();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to link account to user.', {
|
||||||
|
label: 'API',
|
||||||
|
ip: req.ip,
|
||||||
|
error: e,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
e instanceof ApiError &&
|
||||||
|
e.errorCode === ApiErrorCode.InvalidCredentials
|
||||||
|
) {
|
||||||
|
return res.status(401).json({ code: e.errorCode });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(500).send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
userSettingsRoutes.delete<{ id: string }>(
|
||||||
|
'/linked-accounts/jellyfin',
|
||||||
|
isOwnProfileOrAdmin(),
|
||||||
|
async (req, res) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
const userRepository = getRepository(User);
|
||||||
|
|
||||||
|
// Make sure jellyfin login is enabled
|
||||||
|
if (
|
||||||
|
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
||||||
|
settings.main.mediaServerType !== MediaServerType.EMBY
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ message: 'Jellyfin/Emby login is disabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await userRepository
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.addSelect('user.password')
|
||||||
|
.where({
|
||||||
|
id: Number(req.params.id),
|
||||||
|
})
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ message: 'User not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.id === 1) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message:
|
||||||
|
'Cannot unlink media server accounts for the primary administrator.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.email || !user.password) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: 'User does not have a local email or password set.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
user.userType = UserType.LOCAL;
|
||||||
|
user.jellyfinUserId = null;
|
||||||
|
user.jellyfinUsername = null;
|
||||||
|
user.jellyfinAuthToken = null;
|
||||||
|
user.jellyfinDeviceId = null;
|
||||||
|
await userRepository.save(user);
|
||||||
|
|
||||||
|
return res.status(204).send();
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ message: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
|
userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
|
||||||
'/notifications',
|
'/notifications',
|
||||||
isOwnProfileOrAdmin(),
|
isOwnProfileOrAdmin(),
|
||||||
|
|||||||
4
server/types/custom.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
declare module '@dr.pogodin/csurf' {
|
||||||
|
import csrf = require('csurf');
|
||||||
|
export = csrf;
|
||||||
|
}
|
||||||
@@ -8,8 +8,9 @@ export default async function createCustomProxyAgent(
|
|||||||
) {
|
) {
|
||||||
const defaultAgent = new Agent({ keepAliveTimeout: 5000 });
|
const defaultAgent = new Agent({ keepAliveTimeout: 5000 });
|
||||||
|
|
||||||
const skipUrl = (url: string) => {
|
const skipUrl = (url: string | URL) => {
|
||||||
const hostname = new URL(url).hostname;
|
const hostname =
|
||||||
|
typeof url === 'string' ? new URL(url).hostname : url.hostname;
|
||||||
|
|
||||||
if (proxySettings.bypassLocalAddresses && isLocalAddress(hostname)) {
|
if (proxySettings.bypassLocalAddresses && isLocalAddress(hostname)) {
|
||||||
return true;
|
return true;
|
||||||
@@ -38,8 +39,7 @@ export default async function createCustomProxyAgent(
|
|||||||
dispatch: Dispatcher['dispatch']
|
dispatch: Dispatcher['dispatch']
|
||||||
): Dispatcher['dispatch'] => {
|
): Dispatcher['dispatch'] => {
|
||||||
return (opts, handler) => {
|
return (opts, handler) => {
|
||||||
const url = opts.origin?.toString();
|
return opts.origin && skipUrl(opts.origin)
|
||||||
return url && skipUrl(url)
|
|
||||||
? defaultAgent.dispatch(opts, handler)
|
? defaultAgent.dispatch(opts, handler)
|
||||||
: dispatch(opts, handler);
|
: dispatch(opts, handler);
|
||||||
};
|
};
|
||||||
@@ -60,13 +60,10 @@ export default async function createCustomProxyAgent(
|
|||||||
':' +
|
':' +
|
||||||
proxySettings.port,
|
proxySettings.port,
|
||||||
token,
|
token,
|
||||||
interceptors: {
|
|
||||||
Client: [noProxyInterceptor],
|
|
||||||
},
|
|
||||||
keepAliveTimeout: 5000,
|
keepAliveTimeout: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
setGlobalDispatcher(proxyAgent);
|
setGlobalDispatcher(proxyAgent.compose(noProxyInterceptor));
|
||||||
} 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',
|
||||||
@@ -95,7 +92,11 @@ export default async function createCustomProxyAgent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isLocalAddress(hostname: string) {
|
function isLocalAddress(hostname: string) {
|
||||||
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
if (
|
||||||
|
hostname === 'localhost' ||
|
||||||
|
hostname === '127.0.0.1' ||
|
||||||
|
hostname === '::1'
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ class RestartFlag {
|
|||||||
this.networkSettings.csrfProtection !== networkSettings.csrfProtection ||
|
this.networkSettings.csrfProtection !== networkSettings.csrfProtection ||
|
||||||
this.networkSettings.trustProxy !== networkSettings.trustProxy ||
|
this.networkSettings.trustProxy !== networkSettings.trustProxy ||
|
||||||
this.networkSettings.proxy.enabled !== networkSettings.proxy.enabled ||
|
this.networkSettings.proxy.enabled !== networkSettings.proxy.enabled ||
|
||||||
this.networkSettings.forceIpv4First !== networkSettings.forceIpv4First ||
|
this.networkSettings.forceIpv4First !== networkSettings.forceIpv4First
|
||||||
this.networkSettings.dnsServers !== networkSettings.dnsServers
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/assets/services/jellyfin-icon.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- ***** BEGIN LICENSE BLOCK *****
|
||||||
|
- Part of the Jellyfin project (https://jellyfin.media)
|
||||||
|
-
|
||||||
|
- All copyright belongs to the Jellyfin contributors; a full list can
|
||||||
|
- be found in the file CONTRIBUTORS.md
|
||||||
|
-
|
||||||
|
- This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License.
|
||||||
|
- To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/4.0/.
|
||||||
|
- ***** END LICENSE BLOCK ***** -->
|
||||||
|
<svg version="1.1" id="icon-transparent" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="linear-gradient" gradientUnits="userSpaceOnUse" x1="110.25" y1="213.3" x2="496.14" y2="436.09">
|
||||||
|
<stop offset="0" style="stop-color:#AA5CC3"/>
|
||||||
|
<stop offset="1" style="stop-color:#00A4DC"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<title>icon-transparent</title>
|
||||||
|
<g id="icon-transparent">
|
||||||
|
<path id="inner-shape" d="M256,201.6c-20.4,0-86.2,119.3-76.2,139.4s142.5,19.9,152.4,0S276.5,201.6,256,201.6z" fill="url(#linear-gradient)"/>
|
||||||
|
<path id="outer-shape" d="M256,23.3c-61.6,0-259.8,359.4-229.6,420.1s429.3,60,459.2,0S317.6,23.3,256,23.3z
|
||||||
|
M406.5,390.8c-19.6,39.3-281.1,39.8-300.9,0s110.1-275.3,150.4-275.3S426.1,351.4,406.5,390.8z" fill="url(#linear-gradient)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,85 +1,43 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!-- Generator: Adobe Illustrator 26.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 361 157">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
<svg
|
.cls-2 {
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
fill: #eaaf20;
|
||||||
xmlns:cc="http://creativecommons.org/ns#"
|
}
|
||||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
</style>
|
||||||
xmlns:svg="http://www.w3.org/2000/svg"
|
</defs>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<!-- Generator: Adobe Illustrator 28.7.1, SVG Export Plug-In . SVG Version: 1.2.0 Build 142) -->
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
<g>
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
<g id="Layer_1">
|
||||||
version="1.1"
|
<path id="path4" class="cls-1"
|
||||||
id="plex-logo"
|
d="M60.6,28.8c-14.3,0-23.5,3.9-31.3,13v-10H1.6v123.7s.5.2,1.9.5c1.9.5,12.1,2.5,19.6-3.4,6.5-5.3,8-11.4,8-18.3v-17.8c8,8,17,11.4,29.6,11.4,27.2,0,48-20.8,48-48.4s-20.1-50.7-48.3-50.7h0ZM55.2,104.3c-15.3,0-27.4-11.9-27.4-26.3s14.3-25.6,27.4-25.6,27.4,11.2,27.4,25.8-12.1,26-27.4,26Z" />
|
||||||
x="0px"
|
<path id="path6" class="cls-1"
|
||||||
y="0px"
|
d="M148.1,76.5c0,10.7,1.2,23.7,12.4,37.9.2.2.7.9.7.9-4.6,7.3-10.2,12.3-17.7,12.3s-11.6-3-16.5-8c-5.1-5.5-7.5-12.6-7.5-20.1V.9h28.4l.2,75.6Z" />
|
||||||
viewBox="0 0 1000 460.89727"
|
<polygon id="polygon8" class="cls-2"
|
||||||
xml:space="preserve"
|
points="287.6 78.3 254.1 31.7 288.6 31.7 321.8 78.3 288.6 124.6 254.1 124.6 287.6 78.3" />
|
||||||
sodipodi:docname="plex-logo.svg"
|
<polygon id="polygon10" class="cls-1"
|
||||||
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"><metadata
|
points="330.8 73 360.6 31.7 326.2 31.7 313.8 48.9 330.8 73" />
|
||||||
id="metadata25"><rdf:RDF><cc:Work
|
<path id="path12" class="cls-1"
|
||||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
d="M313.8,107.7l5.8,7.5c5.6,8.2,12.9,12.3,21.3,12.3,9-.2,15.3-7.5,17.7-10.3,0,0-4.4-3.7-9.9-9.8-7.5-8.2-17.5-23.3-17.7-24l-17.2,24.2Z" />
|
||||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
<path id="path16" class="cls-1"
|
||||||
id="defs23">
|
d="M228.7,97.9c-5.8,5-9.7,7.8-17.7,7.8-14.3,0-22.6-9.6-23.8-20.1h75.9c.5-1.4.7-3.2.7-6.2,0-29-22.6-50.7-52.2-50.7s-51.2,22.1-51.2,49.8,23,49.1,51.9,49.1,37.6-10.7,47.1-29.7h-30.8ZM211.9,50.7c12.6,0,22.1,7.8,24.3,18h-48c2.4-10.7,11.4-18,23.8-18h0Z" />
|
||||||
</defs><sodipodi:namedview
|
<path id="path4-2" data-name="path4" class="cls-1"
|
||||||
pagecolor="#ffffff"
|
d="M59.3,28.2c-14.3,0-23.5,3.9-31.3,13v-10H.4v123.7s.5.2,1.9.5c1.9.5,12.1,2.5,19.6-3.4,6.5-5.3,8-11.4,8-18.3v-17.8c8,8,17,11.4,29.6,11.4,27.2,0,48-20.8,48-48.4s-20.1-50.7-48.3-50.7h0ZM54,103.8c-15.3,0-27.4-11.9-27.4-26.3s14.3-25.6,27.4-25.6,27.4,11.2,27.4,25.8-12.1,26-27.4,26Z" />
|
||||||
bordercolor="#111111"
|
<path id="path6-2" data-name="path6" class="cls-1"
|
||||||
borderopacity="1"
|
d="M146.9,75.9c0,10.7,1.2,23.7,12.4,37.9.2.2.7.9.7.9-4.6,7.3-10.2,12.3-17.7,12.3s-11.6-3-16.5-8c-5.1-5.5-7.5-12.6-7.5-20.1V.4h28.4l.2,75.6Z" />
|
||||||
objecttolerance="10"
|
<polygon id="polygon8-2" data-name="polygon8" class="cls-2"
|
||||||
gridtolerance="10"
|
points="286.4 77.8 252.9 31.2 287.3 31.2 320.6 77.8 287.3 124.1 252.9 124.1 286.4 77.8" />
|
||||||
guidetolerance="10"
|
<polygon id="polygon10-2" data-name="polygon10" class="cls-1"
|
||||||
inkscape:pageopacity="0"
|
points="329.5 72.5 359.4 31.2 324.9 31.2 312.6 48.3 329.5 72.5" />
|
||||||
inkscape:pageshadow="2"
|
<path id="path12-2" data-name="path12" class="cls-1"
|
||||||
inkscape:window-width="1920"
|
d="M312.6,107.2l5.8,7.5c5.6,8.2,12.9,12.3,21.3,12.3,9-.2,15.3-7.5,17.7-10.3,0,0-4.4-3.7-9.9-9.8-7.5-8.2-17.5-23.3-17.7-24l-17.2,24.2Z" />
|
||||||
inkscape:window-height="1017"
|
<path id="path16-2" data-name="path16" class="cls-1"
|
||||||
id="namedview21"
|
d="M227.4,97.4c-5.8,5-9.7,7.8-17.7,7.8-14.3,0-22.6-9.6-23.8-20.1h75.9c.5-1.4.7-3.2.7-6.2,0-29-22.6-50.7-52.2-50.7s-51.2,22.1-51.2,49.8,23,49.1,51.9,49.1,37.6-10.7,47.1-29.7h-30.8ZM210.7,50.1c12.6,0,22.1,7.8,24.3,18h-48c2.4-10.7,11.4-18,23.8-18h0Z" />
|
||||||
showgrid="false"
|
</g>
|
||||||
fit-margin-top="0"
|
</g>
|
||||||
fit-margin-left="0"
|
|
||||||
fit-margin-right="0"
|
|
||||||
fit-margin-bottom="0"
|
|
||||||
inkscape:zoom="0.27956081"
|
|
||||||
inkscape:cx="783.06912"
|
|
||||||
inkscape:cy="-132.85701"
|
|
||||||
inkscape:window-x="1912"
|
|
||||||
inkscape:window-y="-8"
|
|
||||||
inkscape:window-maximized="1"
|
|
||||||
inkscape:current-layer="plex-logo" />
|
|
||||||
<style
|
|
||||||
type="text/css"
|
|
||||||
id="style2">
|
|
||||||
.st0{fill:#FFFFFF;}
|
|
||||||
.st1{fill:#EBAF00;}
|
|
||||||
</style>
|
|
||||||
<path
|
|
||||||
class="st0"
|
|
||||||
d="m 164.18919,82.43243 c -39.86487,0 -65.540543,11.48648 -87.162163,38.51351 V 91.21621 H 0 v 366.21621 c 0,0 1.3513514,0.67567 5.4054053,1.35135 5.4054057,1.35135 33.7837827,7.43243 54.7297287,-10.13514 18.243244,-15.54054 22.297295,-33.78378 22.297295,-54.05405 v -52.7027 c 22.297301,23.64864 47.297301,33.78378 82.432431,33.78378 75.67567,0 133.78378,-61.48648 133.78378,-143.24323 0,-88.51352 -56.08108,-150 -134.45945,-150 z m -14.86487,223.64864 c -42.56756,0 -76.351351,-35.13513 -76.351351,-77.7027 0,-41.89189 39.864871,-75.67567 76.351351,-75.67567 43.24324,0 76.35135,33.1081 76.35135,76.35135 0,43.24324 -33.78378,77.02702 -76.35135,77.02702 z"
|
|
||||||
id="path4"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
style="fill:#ffffff;stroke-width:6.75675678" /><path
|
|
||||||
class="st0"
|
|
||||||
d="m 408.1081,223.64864 c 0,31.75676 3.37838,70.27027 34.45946,112.16216 0.67567,0.67567 2.02702,2.7027 2.02702,2.7027 -12.83783,21.62162 -28.37837,36.48648 -49.32432,36.48648 -16.21622,0 -32.43243,-8.78378 -45.94595,-23.64864 -14.18918,-16.21622 -20.94594,-37.16216 -20.94594,-59.45946 V 0 h 79.05405 z"
|
|
||||||
id="path6"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
style="fill:#ffffff;stroke-width:6.75675678" /><polygon
|
|
||||||
class="st1"
|
|
||||||
points="117.9,33.9 104.1,13.5 118.3,13.5 132,33.9 118.3,54.2 104.1,54.2 "
|
|
||||||
id="polygon8"
|
|
||||||
style="fill:#ebaf00"
|
|
||||||
transform="scale(6.7567568)" /><polygon
|
|
||||||
class="st0"
|
|
||||||
points="135.7,31.6 148,13.5 133.8,13.5 128.7,21 "
|
|
||||||
id="polygon10"
|
|
||||||
style="fill:#ffffff"
|
|
||||||
transform="scale(6.7567568)" /><path
|
|
||||||
class="st0"
|
|
||||||
d="m 869.59458,316.2162 c 0,0 16.2162,22.2973 16.2162,22.2973 15.54058,24.32432 35.8108,36.48648 59.45949,36.48648 25,-0.67567 42.56752,-22.29729 49.3243,-30.4054 0,0 -12.16218,-10.81081 -27.7027,-29.05405 -20.94598,-24.32432 -48.64868,-68.91892 -49.3243,-70.94594 z"
|
|
||||||
id="path12"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
style="fill:#ffffff;stroke-width:6.75675678" /><path
|
|
||||||
style="fill:#ffffff;stroke-width:6.75675678"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
id="path16"
|
|
||||||
d="m 632.43242,287.16215 c -16.21622,14.86486 -27.02703,22.97297 -49.32432,22.97297 -39.86487,0 -62.83784,-28.37837 -66.21622,-59.45945 h 211.4865 c 1.35131,-4.05406 2.027,-9.45946 2.027,-18.24324 0,-85.81082 -62.83783,-150 -145.27026,-150 -78.37837,0 -142.56756,65.54054 -142.56756,147.29729 0,81.08108 64.18919,145.27026 144.59459,145.27026 56.08108,0 104.72973,-31.75675 131.08105,-87.83783 z M 585.8108,147.29729 c 35.13513,0 61.48648,22.97297 67.56756,53.37838 H 519.59458 c 6.75676,-31.75676 31.75676,-53.37838 66.21622,-53.37838 z"
|
|
||||||
class="st0" />
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -17,13 +17,10 @@ const AirDateBadge = ({ airDate }: AirDateBadgeProps) => {
|
|||||||
const dAirDate = new Date(airDate);
|
const dAirDate = new Date(airDate);
|
||||||
const nowDate = new Date();
|
const nowDate = new Date();
|
||||||
const alreadyAired = dAirDate.getTime() < nowDate.getTime();
|
const alreadyAired = dAirDate.getTime() < nowDate.getTime();
|
||||||
|
|
||||||
const compareWeek = new Date(
|
const compareWeek = new Date(
|
||||||
alreadyAired ? Date.now() - WEEK : Date.now() + WEEK
|
alreadyAired ? Date.now() - WEEK : Date.now() + WEEK
|
||||||
);
|
);
|
||||||
|
|
||||||
let showRelative = false;
|
let showRelative = false;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(alreadyAired && dAirDate.getTime() > compareWeek.getTime()) ||
|
(alreadyAired && dAirDate.getTime() > compareWeek.getTime()) ||
|
||||||
(!alreadyAired && dAirDate.getTime() < compareWeek.getTime())
|
(!alreadyAired && dAirDate.getTime() < compareWeek.getTime())
|
||||||
@@ -31,6 +28,10 @@ const AirDateBadge = ({ airDate }: AirDateBadgeProps) => {
|
|||||||
showRelative = true;
|
showRelative = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const diffInDays = Math.round(
|
||||||
|
(dAirDate.getTime() - nowDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Badge badgeType="light">
|
<Badge badgeType="light">
|
||||||
@@ -48,9 +49,9 @@ const AirDateBadge = ({ airDate }: AirDateBadgeProps) => {
|
|||||||
{
|
{
|
||||||
relativeTime: (
|
relativeTime: (
|
||||||
<FormattedRelativeTime
|
<FormattedRelativeTime
|
||||||
value={(dAirDate.getTime() - Date.now()) / 1000}
|
value={diffInDays}
|
||||||
|
unit="day"
|
||||||
numeric="auto"
|
numeric="auto"
|
||||||
updateIntervalInSeconds={1}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
src={
|
src={
|
||||||
title?.posterPath
|
title?.posterPath
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||||
: '/images/overseerr_poster_not_found.png'
|
: '/images/jellyseerr_poster_not_found.png'
|
||||||
}
|
}
|
||||||
alt=""
|
alt=""
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
src={
|
src={
|
||||||
data.posterPath
|
data.posterPath
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
||||||
: '/images/overseerr_poster_not_found.png'
|
: '/images/jellyseerr_poster_not_found.png'
|
||||||
}
|
}
|
||||||
alt=""
|
alt=""
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ForwardedRef } from 'react';
|
import type { ForwardedRef } from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
export type ButtonType =
|
export type ButtonType =
|
||||||
| 'default'
|
| 'default'
|
||||||
@@ -97,7 +98,7 @@ function Button<P extends ElementTypes = 'button'>(
|
|||||||
if (as === 'a') {
|
if (as === 'a') {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
className={buttonStyle.join(' ')}
|
className={twMerge(buttonStyle)}
|
||||||
{...(props as React.ComponentProps<'a'>)}
|
{...(props as React.ComponentProps<'a'>)}
|
||||||
ref={ref as ForwardedRef<HTMLAnchorElement>}
|
ref={ref as ForwardedRef<HTMLAnchorElement>}
|
||||||
>
|
>
|
||||||
@@ -107,7 +108,7 @@ function Button<P extends ElementTypes = 'button'>(
|
|||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={buttonStyle.join(' ')}
|
className={twMerge(buttonStyle)}
|
||||||
{...(props as React.ComponentProps<'button'>)}
|
{...(props as React.ComponentProps<'button'>)}
|
||||||
ref={ref as ForwardedRef<HTMLButtonElement>}
|
ref={ref as ForwardedRef<HTMLButtonElement>}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,77 +1,29 @@
|
|||||||
import useClickOutside from '@app/hooks/useClickOutside';
|
import Dropdown from '@app/components/Common/Dropdown';
|
||||||
import { withProperties } from '@app/utils/typeHelpers';
|
import { withProperties } from '@app/utils/typeHelpers';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Menu } from '@headlessui/react';
|
||||||
import { ChevronDownIcon } from '@heroicons/react/24/solid';
|
import { ChevronDownIcon } from '@heroicons/react/24/solid';
|
||||||
import type {
|
import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react';
|
||||||
AnchorHTMLAttributes,
|
|
||||||
ButtonHTMLAttributes,
|
|
||||||
RefObject,
|
|
||||||
} from 'react';
|
|
||||||
import { Fragment, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
interface DropdownItemProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
type ButtonWithDropdownProps = {
|
||||||
buttonType?: 'primary' | 'ghost';
|
|
||||||
}
|
|
||||||
|
|
||||||
const DropdownItem = ({
|
|
||||||
children,
|
|
||||||
buttonType = 'primary',
|
|
||||||
...props
|
|
||||||
}: DropdownItemProps) => {
|
|
||||||
let styleClass = 'button-md text-white';
|
|
||||||
|
|
||||||
switch (buttonType) {
|
|
||||||
case 'ghost':
|
|
||||||
styleClass +=
|
|
||||||
' bg-transparent rounded hover:bg-gradient-to-br from-indigo-600 to-purple-600 text-white focus:border-gray-500 focus:text-white';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
styleClass +=
|
|
||||||
' bg-indigo-600 rounded hover:bg-indigo-500 focus:border-indigo-700 focus:text-white';
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
className={`flex cursor-pointer items-center px-4 py-2 text-sm leading-5 focus:outline-none ${styleClass}`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ButtonWithDropdownProps {
|
|
||||||
text: React.ReactNode;
|
text: React.ReactNode;
|
||||||
dropdownIcon?: React.ReactNode;
|
dropdownIcon?: React.ReactNode;
|
||||||
buttonType?: 'primary' | 'ghost';
|
buttonType?: 'primary' | 'ghost';
|
||||||
}
|
} & (
|
||||||
interface ButtonProps
|
| ({ as?: 'button' } & ButtonHTMLAttributes<HTMLButtonElement>)
|
||||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
| ({ as: 'a' } & AnchorHTMLAttributes<HTMLAnchorElement>)
|
||||||
ButtonWithDropdownProps {
|
);
|
||||||
as?: 'button';
|
|
||||||
}
|
|
||||||
interface AnchorProps
|
|
||||||
extends AnchorHTMLAttributes<HTMLAnchorElement>,
|
|
||||||
ButtonWithDropdownProps {
|
|
||||||
as: 'a';
|
|
||||||
}
|
|
||||||
|
|
||||||
const ButtonWithDropdown = ({
|
const ButtonWithDropdown = ({
|
||||||
as,
|
|
||||||
text,
|
text,
|
||||||
children,
|
children,
|
||||||
dropdownIcon,
|
dropdownIcon,
|
||||||
className,
|
className,
|
||||||
buttonType = 'primary',
|
buttonType = 'primary',
|
||||||
...props
|
...props
|
||||||
}: ButtonProps | AnchorProps) => {
|
}: ButtonWithDropdownProps) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const buttonRef = useRef<HTMLButtonElement | HTMLAnchorElement>(null);
|
|
||||||
useClickOutside(buttonRef, () => setIsOpen(false));
|
|
||||||
|
|
||||||
const styleClasses = {
|
const styleClasses = {
|
||||||
mainButtonClasses: 'button-md text-white border',
|
mainButtonClasses: 'button-md text-white border',
|
||||||
dropdownSideButtonClasses: 'button-md border',
|
dropdownSideButtonClasses: 'button-md border',
|
||||||
dropdownClasses: 'button-md',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (buttonType) {
|
switch (buttonType) {
|
||||||
@@ -79,72 +31,40 @@ const ButtonWithDropdown = ({
|
|||||||
styleClasses.mainButtonClasses +=
|
styleClasses.mainButtonClasses +=
|
||||||
' bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
|
' bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
|
||||||
styleClasses.dropdownSideButtonClasses = styleClasses.mainButtonClasses;
|
styleClasses.dropdownSideButtonClasses = styleClasses.mainButtonClasses;
|
||||||
styleClasses.dropdownClasses +=
|
|
||||||
' bg-gray-800 border border-gray-700 bg-opacity-80 p-1 backdrop-blur';
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
styleClasses.mainButtonClasses +=
|
styleClasses.mainButtonClasses +=
|
||||||
' bg-indigo-600 border-indigo-500 bg-opacity-80 hover:bg-opacity-100 hover:border-indigo-500 active:bg-indigo-700 active:border-indigo-700 focus:ring-blue';
|
' bg-indigo-600 border-indigo-500 bg-opacity-80 hover:bg-opacity-100 hover:border-indigo-500 active:bg-indigo-700 active:border-indigo-700 focus:ring-blue';
|
||||||
styleClasses.dropdownSideButtonClasses +=
|
styleClasses.dropdownSideButtonClasses +=
|
||||||
' bg-indigo-600 bg-opacity-80 border-indigo-500 hover:bg-opacity-100 active:bg-opacity-100 focus:ring-blue';
|
' bg-indigo-600 bg-opacity-80 border-indigo-500 hover:bg-opacity-100 active:bg-opacity-100 focus:ring-blue';
|
||||||
styleClasses.dropdownClasses += ' bg-indigo-600 p-1';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TriggerElement = props.as ?? 'button';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="relative inline-flex h-full rounded-md shadow-sm">
|
<Menu as="div" className="relative z-10 inline-flex">
|
||||||
{as === 'a' ? (
|
<TriggerElement
|
||||||
<a
|
type="button"
|
||||||
className={`relative z-10 inline-flex h-full items-center px-4 py-2 text-sm font-medium leading-5 transition duration-150 ease-in-out hover:z-20 focus:z-20 focus:outline-none ${
|
className={`relative z-10 inline-flex h-full items-center px-4 py-2 text-sm font-medium leading-5 transition duration-150 ease-in-out hover:z-20 focus:z-20 focus:outline-none ${
|
||||||
styleClasses.mainButtonClasses
|
styleClasses.mainButtonClasses
|
||||||
} ${children ? 'rounded-l-md' : 'rounded-md'} ${className}`}
|
} ${children ? 'rounded-l-md' : 'rounded-md'} ${className}`}
|
||||||
ref={buttonRef as RefObject<HTMLAnchorElement>}
|
{...(props as Record<string, string>)}
|
||||||
{...(props as AnchorHTMLAttributes<HTMLAnchorElement>)}
|
>
|
||||||
>
|
{text}
|
||||||
{text}
|
</TriggerElement>
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`relative z-10 inline-flex h-full items-center px-4 py-2 text-sm font-medium leading-5 transition duration-150 ease-in-out hover:z-20 focus:z-20 focus:outline-none ${
|
|
||||||
styleClasses.mainButtonClasses
|
|
||||||
} ${children ? 'rounded-l-md' : 'rounded-md'} ${className}`}
|
|
||||||
ref={buttonRef as RefObject<HTMLButtonElement>}
|
|
||||||
{...(props as ButtonHTMLAttributes<HTMLButtonElement>)}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{children && (
|
{children && (
|
||||||
<span className="relative -ml-px block">
|
<span className="relative -ml-px block">
|
||||||
<button
|
<Menu.Button
|
||||||
type="button"
|
type="button"
|
||||||
className={`relative z-10 inline-flex h-full items-center rounded-r-md px-2 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out hover:z-20 focus:z-20 ${styleClasses.dropdownSideButtonClasses}`}
|
className={`relative z-10 inline-flex h-full items-center rounded-r-md px-2 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out hover:z-20 focus:z-20 ${styleClasses.dropdownSideButtonClasses}`}
|
||||||
aria-label="Expand"
|
aria-label="Expand"
|
||||||
onClick={() => setIsOpen((state) => !state)}
|
|
||||||
>
|
>
|
||||||
{dropdownIcon ? dropdownIcon : <ChevronDownIcon />}
|
{dropdownIcon ? dropdownIcon : <ChevronDownIcon />}
|
||||||
</button>
|
</Menu.Button>
|
||||||
<Transition
|
<Dropdown.Items dropdownType={buttonType}>{children}</Dropdown.Items>
|
||||||
as={Fragment}
|
|
||||||
show={isOpen}
|
|
||||||
enter="transition ease-out duration-100"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="transition ease-in duration-75"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<div className="absolute right-0 z-40 mt-2 -mr-1 w-56 origin-top-right rounded-md shadow-lg">
|
|
||||||
<div
|
|
||||||
className={`rounded-md ring-1 ring-black ring-opacity-5 ${styleClasses.dropdownClasses}`}
|
|
||||||
>
|
|
||||||
<div className="py-1">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</Menu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default withProperties(ButtonWithDropdown, { Item: DropdownItem });
|
export default withProperties(ButtonWithDropdown, { Item: Dropdown.Item });
|
||||||
|
|||||||
117
src/components/Common/Dropdown/index.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { withProperties } from '@app/utils/typeHelpers';
|
||||||
|
import { Menu, Transition } from '@headlessui/react';
|
||||||
|
import { ChevronDownIcon } from '@heroicons/react/24/solid';
|
||||||
|
import {
|
||||||
|
Fragment,
|
||||||
|
useRef,
|
||||||
|
type AnchorHTMLAttributes,
|
||||||
|
type ButtonHTMLAttributes,
|
||||||
|
type HTMLAttributes,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
interface DropdownItemProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||||
|
buttonType?: 'primary' | 'ghost';
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropdownItem = ({
|
||||||
|
children,
|
||||||
|
buttonType = 'primary',
|
||||||
|
...props
|
||||||
|
}: DropdownItemProps) => {
|
||||||
|
return (
|
||||||
|
<Menu.Item>
|
||||||
|
<a
|
||||||
|
className={[
|
||||||
|
'button-md flex cursor-pointer items-center rounded px-4 py-2 text-sm leading-5 text-white focus:text-white focus:outline-none',
|
||||||
|
buttonType === 'ghost'
|
||||||
|
? 'bg-transparent from-indigo-600 to-purple-600 hover:bg-gradient-to-br focus:border-gray-500'
|
||||||
|
: 'bg-indigo-600 hover:bg-indigo-500 focus:border-indigo-700',
|
||||||
|
].join(' ')}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
</Menu.Item>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type DropdownItemsProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
dropdownType: 'primary' | 'ghost';
|
||||||
|
};
|
||||||
|
|
||||||
|
const DropdownItems = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
dropdownType,
|
||||||
|
...props
|
||||||
|
}: DropdownItemsProps) => {
|
||||||
|
return (
|
||||||
|
<Transition
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition ease-out duration-100"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="transition ease-in duration-75"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<Menu.Items
|
||||||
|
className={[
|
||||||
|
'absolute right-0 z-40 mt-2 -mr-1 w-56 origin-top-right rounded-md p-1 shadow-lg',
|
||||||
|
dropdownType === 'ghost'
|
||||||
|
? 'border border-gray-700 bg-gray-800 bg-opacity-80 backdrop-blur'
|
||||||
|
: 'bg-indigo-600',
|
||||||
|
className,
|
||||||
|
].join(' ')}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="py-1">{children}</div>
|
||||||
|
</Menu.Items>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DropdownProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
text: React.ReactNode;
|
||||||
|
dropdownIcon?: React.ReactNode;
|
||||||
|
buttonType?: 'primary' | 'ghost';
|
||||||
|
}
|
||||||
|
|
||||||
|
const Dropdown = ({
|
||||||
|
text,
|
||||||
|
children,
|
||||||
|
dropdownIcon,
|
||||||
|
className,
|
||||||
|
buttonType = 'primary',
|
||||||
|
...props
|
||||||
|
}: DropdownProps) => {
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu as="div" className="relative z-10">
|
||||||
|
<Menu.Button
|
||||||
|
type="button"
|
||||||
|
className={[
|
||||||
|
'button-md inline-flex h-full items-center space-x-2 rounded-md border px-4 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out hover:z-20 focus:z-20 focus:outline-none',
|
||||||
|
buttonType === 'ghost'
|
||||||
|
? 'border-gray-600 bg-transparent hover:border-gray-200 focus:border-gray-100 active:border-gray-100'
|
||||||
|
: 'focus:ring-blue border-indigo-500 bg-indigo-600 bg-opacity-80 hover:border-indigo-500 hover:bg-opacity-100 active:border-indigo-700 active:bg-indigo-700',
|
||||||
|
className,
|
||||||
|
].join(' ')}
|
||||||
|
ref={buttonRef}
|
||||||
|
disabled={!children}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span>{text}</span>
|
||||||
|
{children && (dropdownIcon ? dropdownIcon : <ChevronDownIcon />)}
|
||||||
|
</Menu.Button>
|
||||||
|
{children && (
|
||||||
|
<DropdownItems dropdownType={buttonType}>{children}</DropdownItems>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default withProperties(Dropdown, {
|
||||||
|
Item: DropdownItem,
|
||||||
|
Items: DropdownItems,
|
||||||
|
});
|
||||||
44
src/components/Common/LabeledCheckbox/index.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Field } from 'formik';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
interface LabeledCheckboxProps {
|
||||||
|
id: string;
|
||||||
|
className?: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
onChange: () => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LabeledCheckbox: React.FC<LabeledCheckboxProps> = ({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
onChange,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={twMerge('relative flex items-start', className)}>
|
||||||
|
<div className="flex h-6 items-center">
|
||||||
|
<Field type="checkbox" id={id} name={id} onChange={onChange} />
|
||||||
|
</div>
|
||||||
|
<div className="ml-3 text-sm leading-6">
|
||||||
|
<label htmlFor="localLogin" className="block">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium text-white">{label}</span>
|
||||||
|
<span className="font-normal text-gray-400">{description}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
/* can hold child checkboxes */
|
||||||
|
children && <div className="mt-4 pl-10">{children}</div>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LabeledCheckbox;
|
||||||
@@ -29,11 +29,16 @@ interface ModalProps {
|
|||||||
secondaryDisabled?: boolean;
|
secondaryDisabled?: boolean;
|
||||||
tertiaryDisabled?: boolean;
|
tertiaryDisabled?: boolean;
|
||||||
tertiaryButtonType?: ButtonType;
|
tertiaryButtonType?: ButtonType;
|
||||||
|
okButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||||
|
cancelButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||||
|
secondaryButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||||
|
tertiaryButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||||
disableScrollLock?: boolean;
|
disableScrollLock?: boolean;
|
||||||
backgroundClickable?: boolean;
|
backgroundClickable?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
backdrop?: string;
|
backdrop?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
dialogClass?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||||
@@ -61,6 +66,11 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
loading = false,
|
loading = false,
|
||||||
onTertiary,
|
onTertiary,
|
||||||
backdrop,
|
backdrop,
|
||||||
|
dialogClass,
|
||||||
|
okButtonProps,
|
||||||
|
cancelButtonProps,
|
||||||
|
secondaryButtonProps,
|
||||||
|
tertiaryButtonProps,
|
||||||
},
|
},
|
||||||
parentRef
|
parentRef
|
||||||
) => {
|
) => {
|
||||||
@@ -106,7 +116,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
<Transition
|
<Transition
|
||||||
className="hide-scrollbar relative inline-block w-full overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle"
|
className={`hide-scrollbar relative inline-block w-full overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle ${dialogClass}`}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="modal-headline"
|
aria-labelledby="modal-headline"
|
||||||
@@ -189,6 +199,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
className="ml-3"
|
className="ml-3"
|
||||||
disabled={okDisabled}
|
disabled={okDisabled}
|
||||||
data-testid="modal-ok-button"
|
data-testid="modal-ok-button"
|
||||||
|
{...okButtonProps}
|
||||||
>
|
>
|
||||||
{okText ? okText : 'Ok'}
|
{okText ? okText : 'Ok'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -200,6 +211,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
className="ml-3"
|
className="ml-3"
|
||||||
disabled={secondaryDisabled}
|
disabled={secondaryDisabled}
|
||||||
data-testid="modal-secondary-button"
|
data-testid="modal-secondary-button"
|
||||||
|
{...secondaryButtonProps}
|
||||||
>
|
>
|
||||||
{secondaryText}
|
{secondaryText}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -210,6 +222,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
onClick={onTertiary}
|
onClick={onTertiary}
|
||||||
className="ml-3"
|
className="ml-3"
|
||||||
disabled={tertiaryDisabled}
|
disabled={tertiaryDisabled}
|
||||||
|
{...tertiaryButtonProps}
|
||||||
>
|
>
|
||||||
{tertiaryText}
|
{tertiaryText}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -220,6 +233,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="ml-3 sm:ml-0"
|
className="ml-3 sm:ml-0"
|
||||||
data-testid="modal-cancel-button"
|
data-testid="modal-cancel-button"
|
||||||
|
{...cancelButtonProps}
|
||||||
>
|
>
|
||||||
{cancelText
|
{cancelText
|
||||||
? cancelText
|
? cancelText
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ const SensitiveInput = ({ as = 'input', ...props }: SensitiveInputProps) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Component
|
<Component
|
||||||
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
{...componentProps}
|
{...componentProps}
|
||||||
className={`rounded-l-only ${componentProps.className ?? ''}`}
|
className={`rounded-l-only ${componentProps.className ?? ''}`}
|
||||||
type={
|
type={
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import { useRouter } from 'next/router';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FormattedRelativeTime, useIntl } from 'react-intl';
|
import { FormattedRelativeTime, useIntl } from 'react-intl';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import useSWR from 'swr';
|
import useSWR, { mutate } from 'swr';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
const messages = defineMessages('components.IssueDetails', {
|
const messages = defineMessages('components.IssueDetails', {
|
||||||
@@ -155,6 +155,7 @@ const IssueDetails = () => {
|
|||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
});
|
});
|
||||||
revalidateIssue();
|
revalidateIssue();
|
||||||
|
mutate('/api/v1/issue/count');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addToast(intl.formatMessage(messages.toaststatusupdatefailed), {
|
addToast(intl.formatMessage(messages.toaststatusupdatefailed), {
|
||||||
appearance: 'error',
|
appearance: 'error',
|
||||||
@@ -169,6 +170,7 @@ const IssueDetails = () => {
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
|
mutate('/api/v1/issue/count');
|
||||||
|
|
||||||
addToast(intl.formatMessage(messages.toastissuedeleted), {
|
addToast(intl.formatMessage(messages.toastissuedeleted), {
|
||||||
appearance: 'success',
|
appearance: 'success',
|
||||||
@@ -240,7 +242,7 @@ const IssueDetails = () => {
|
|||||||
src={
|
src={
|
||||||
data.posterPath
|
data.posterPath
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
||||||
: '/images/overseerr_poster_not_found.png'
|
: '/images/jellyseerr_poster_not_found.png'
|
||||||
}
|
}
|
||||||
alt=""
|
alt=""
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
|||||||
src={
|
src={
|
||||||
title.posterPath
|
title.posterPath
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||||
: '/images/overseerr_poster_not_found.png'
|
: '/images/jellyseerr_poster_not_found.png'
|
||||||
}
|
}
|
||||||
alt=""
|
alt=""
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { Field, Formik } from 'formik';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import useSWR from 'swr';
|
import useSWR, { mutate } from 'swr';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
const messages = defineMessages('components.IssueModal.CreateIssueModal', {
|
const messages = defineMessages('components.IssueModal.CreateIssueModal', {
|
||||||
@@ -138,6 +138,8 @@ const CreateIssueModal = ({
|
|||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
mutate('/api/v1/issue/count');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onCancel) {
|
if (onCancel) {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ interface LanguageSelectorProps {
|
|||||||
setFieldValue: (property: string, value: string) => void;
|
setFieldValue: (property: string, value: string) => void;
|
||||||
serverValue?: string;
|
serverValue?: string;
|
||||||
isUserSettings?: boolean;
|
isUserSettings?: boolean;
|
||||||
|
isDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LanguageSelector = ({
|
const LanguageSelector = ({
|
||||||
@@ -40,6 +41,7 @@ const LanguageSelector = ({
|
|||||||
setFieldValue,
|
setFieldValue,
|
||||||
serverValue,
|
serverValue,
|
||||||
isUserSettings = false,
|
isUserSettings = false,
|
||||||
|
isDisabled,
|
||||||
}: LanguageSelectorProps) => {
|
}: LanguageSelectorProps) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { data: languages } = useSWR<Language[]>('/api/v1/languages');
|
const { data: languages } = useSWR<Language[]>('/api/v1/languages');
|
||||||
@@ -96,6 +98,7 @@ const LanguageSelector = ({
|
|||||||
<Select<OptionType, true>
|
<Select<OptionType, true>
|
||||||
options={options}
|
options={options}
|
||||||
isMulti
|
isMulti
|
||||||
|
isDisabled={isDisabled}
|
||||||
className="react-select-container"
|
className="react-select-container"
|
||||||
classNamePrefix="react-select"
|
classNamePrefix="react-select"
|
||||||
value={
|
value={
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Badge from '@app/components/Common/Badge';
|
||||||
import { menuMessages } from '@app/components/Layout/Sidebar';
|
import { menuMessages } from '@app/components/Layout/Sidebar';
|
||||||
import useClickOutside from '@app/hooks/useClickOutside';
|
import useClickOutside from '@app/hooks/useClickOutside';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
@@ -26,9 +27,16 @@ import {
|
|||||||
} from '@heroicons/react/24/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { cloneElement, useRef, useState } from 'react';
|
import { cloneElement, useEffect, useRef, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
interface MobileMenuProps {
|
||||||
|
pendingRequestsCount: number;
|
||||||
|
openIssuesCount: number;
|
||||||
|
revalidateIssueCount: () => void;
|
||||||
|
revalidateRequestsCount: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface MenuLink {
|
interface MenuLink {
|
||||||
href: string;
|
href: string;
|
||||||
svgIcon: JSX.Element;
|
svgIcon: JSX.Element;
|
||||||
@@ -41,7 +49,12 @@ interface MenuLink {
|
|||||||
dataTestId?: string;
|
dataTestId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MobileMenu = () => {
|
const MobileMenu = ({
|
||||||
|
pendingRequestsCount,
|
||||||
|
openIssuesCount,
|
||||||
|
revalidateIssueCount,
|
||||||
|
revalidateRequestsCount,
|
||||||
|
}: MobileMenuProps) => {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -139,6 +152,21 @@ const MobileMenu = () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (openIssuesCount) {
|
||||||
|
revalidateIssueCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingRequestsCount) {
|
||||||
|
revalidateRequestsCount();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
revalidateIssueCount,
|
||||||
|
revalidateRequestsCount,
|
||||||
|
pendingRequestsCount,
|
||||||
|
openIssuesCount,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 z-50">
|
<div className="fixed bottom-0 left-0 right-0 z-50">
|
||||||
<Transition
|
<Transition
|
||||||
@@ -159,7 +187,7 @@ const MobileMenu = () => {
|
|||||||
<Link
|
<Link
|
||||||
key={`mobile-menu-link-${link.href}`}
|
key={`mobile-menu-link-${link.href}`}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
className={`flex items-center space-x-2 ${
|
className={`flex items-center ${
|
||||||
isActive ? 'text-indigo-500' : ''
|
isActive ? 'text-indigo-500' : ''
|
||||||
}`}
|
}`}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -174,7 +202,25 @@ const MobileMenu = () => {
|
|||||||
{cloneElement(isActive ? link.svgIconSelected : link.svgIcon, {
|
{cloneElement(isActive ? link.svgIconSelected : link.svgIcon, {
|
||||||
className: 'h-5 w-5',
|
className: 'h-5 w-5',
|
||||||
})}
|
})}
|
||||||
<span>{link.content}</span>
|
<span className="ml-2">{link.content}</span>
|
||||||
|
{link.href === '/requests' &&
|
||||||
|
pendingRequestsCount > 0 &&
|
||||||
|
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||||
|
<div className="ml-auto flex">
|
||||||
|
<Badge className="rounded-md border-indigo-500 bg-gradient-to-br from-indigo-600 to-purple-600">
|
||||||
|
{pendingRequestsCount}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{link.href === '/issues' &&
|
||||||
|
openIssuesCount > 0 &&
|
||||||
|
hasPermission(Permission.MANAGE_ISSUES) && (
|
||||||
|
<div className="ml-auto flex">
|
||||||
|
<Badge className="rounded-md border-indigo-500 bg-gradient-to-br from-indigo-600 to-purple-600">
|
||||||
|
{openIssuesCount}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -190,7 +236,7 @@ const MobileMenu = () => {
|
|||||||
<Link
|
<Link
|
||||||
key={`mobile-menu-link-${link.href}`}
|
key={`mobile-menu-link-${link.href}`}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
className={`flex flex-col items-center space-y-1 ${
|
className={`relative flex flex-col items-center space-y-1 ${
|
||||||
isActive ? 'text-indigo-500' : ''
|
isActive ? 'text-indigo-500' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -200,6 +246,23 @@ const MobileMenu = () => {
|
|||||||
className: 'h-6 w-6',
|
className: 'h-6 w-6',
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
|
{link.href === '/requests' &&
|
||||||
|
pendingRequestsCount > 0 &&
|
||||||
|
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||||
|
<div className="absolute left-3 bottom-3">
|
||||||
|
<Badge
|
||||||
|
className={`bg-gradient-to-br ${
|
||||||
|
router.pathname.match(link.activeRegExp)
|
||||||
|
? 'border-indigo-600 from-indigo-700 to-purple-700'
|
||||||
|
: 'border-indigo-500 from-indigo-600 to-purple-600'
|
||||||
|
} flex h-4 w-4 items-center justify-center !px-[9px] !py-[9px] text-[9px]`}
|
||||||
|
>
|
||||||
|
{pendingRequestsCount > 99
|
||||||
|
? '99+'
|
||||||
|
: pendingRequestsCount}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Badge from '@app/components/Common/Badge';
|
||||||
import UserWarnings from '@app/components/Layout/UserWarnings';
|
import UserWarnings from '@app/components/Layout/UserWarnings';
|
||||||
import VersionStatus from '@app/components/Layout/VersionStatus';
|
import VersionStatus from '@app/components/Layout/VersionStatus';
|
||||||
import useClickOutside from '@app/hooks/useClickOutside';
|
import useClickOutside from '@app/hooks/useClickOutside';
|
||||||
@@ -18,7 +19,7 @@ import {
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { Fragment, useRef } from 'react';
|
import { Fragment, useEffect, useRef } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
export const menuMessages = defineMessages('components.Layout.Sidebar', {
|
export const menuMessages = defineMessages('components.Layout.Sidebar', {
|
||||||
@@ -35,6 +36,10 @@ export const menuMessages = defineMessages('components.Layout.Sidebar', {
|
|||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
setClosed: () => void;
|
setClosed: () => void;
|
||||||
|
pendingRequestsCount: number;
|
||||||
|
openIssuesCount: number;
|
||||||
|
revalidateIssueCount: () => void;
|
||||||
|
revalidateRequestsCount: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarLinkProps {
|
interface SidebarLinkProps {
|
||||||
@@ -114,13 +119,35 @@ const SidebarLinks: SidebarLinkProps[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const Sidebar = ({ open, setClosed }: SidebarProps) => {
|
const Sidebar = ({
|
||||||
|
open,
|
||||||
|
setClosed,
|
||||||
|
pendingRequestsCount,
|
||||||
|
openIssuesCount,
|
||||||
|
revalidateIssueCount,
|
||||||
|
revalidateRequestsCount,
|
||||||
|
}: SidebarProps) => {
|
||||||
const navRef = useRef<HTMLDivElement>(null);
|
const navRef = useRef<HTMLDivElement>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { hasPermission } = useUser();
|
const { hasPermission } = useUser();
|
||||||
useClickOutside(navRef, () => setClosed());
|
useClickOutside(navRef, () => setClosed());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (openIssuesCount) {
|
||||||
|
revalidateIssueCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingRequestsCount) {
|
||||||
|
revalidateRequestsCount();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
revalidateIssueCount,
|
||||||
|
revalidateRequestsCount,
|
||||||
|
pendingRequestsCount,
|
||||||
|
openIssuesCount,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="lg:hidden">
|
<div className="lg:hidden">
|
||||||
@@ -253,18 +280,48 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
|
|||||||
href={sidebarLink.href}
|
href={sidebarLink.href}
|
||||||
as={sidebarLink.as}
|
as={sidebarLink.as}
|
||||||
className={`group flex items-center rounded-md px-2 py-2 text-lg font-medium leading-6 text-white transition duration-150 ease-in-out focus:outline-none
|
className={`group flex items-center rounded-md px-2 py-2 text-lg font-medium leading-6 text-white transition duration-150 ease-in-out focus:outline-none
|
||||||
${
|
${
|
||||||
router.pathname.match(sidebarLink.activeRegExp)
|
router.pathname.match(sidebarLink.activeRegExp)
|
||||||
? 'bg-gradient-to-br from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500'
|
? 'bg-gradient-to-br from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500'
|
||||||
: 'hover:bg-gray-700 focus:bg-gray-700'
|
: 'hover:bg-gray-700 focus:bg-gray-700'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
data-testid={sidebarLink.dataTestId}
|
data-testid={sidebarLink.dataTestId}
|
||||||
>
|
>
|
||||||
{sidebarLink.svgIcon}
|
{sidebarLink.svgIcon}
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
menuMessages[sidebarLink.messagesKey]
|
menuMessages[sidebarLink.messagesKey]
|
||||||
)}
|
)}
|
||||||
|
{sidebarLink.messagesKey === 'requests' &&
|
||||||
|
pendingRequestsCount > 0 &&
|
||||||
|
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||||
|
<div className="ml-auto flex">
|
||||||
|
<Badge
|
||||||
|
className={`rounded-md bg-gradient-to-br ${
|
||||||
|
router.pathname.match(sidebarLink.activeRegExp)
|
||||||
|
? 'border-indigo-600 from-indigo-700 to-purple-700'
|
||||||
|
: 'border-indigo-500 from-indigo-600 to-purple-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pendingRequestsCount}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sidebarLink.messagesKey === 'issues' &&
|
||||||
|
openIssuesCount > 0 &&
|
||||||
|
hasPermission(Permission.MANAGE_ISSUES) && (
|
||||||
|
<div className="ml-auto flex">
|
||||||
|
<Badge
|
||||||
|
className={`rounded-md bg-gradient-to-br ${
|
||||||
|
router.pathname.match(sidebarLink.activeRegExp)
|
||||||
|
? 'border-indigo-600 from-indigo-700 to-purple-700'
|
||||||
|
: 'border-indigo-500 from-indigo-600 to-purple-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{openIssuesCount}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useUser } from '@app/hooks/useUser';
|
|||||||
import { ArrowLeftIcon, Bars3BottomLeftIcon } from '@heroicons/react/24/solid';
|
import { ArrowLeftIcon, Bars3BottomLeftIcon } from '@heroicons/react/24/solid';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
type LayoutProps = {
|
type LayoutProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -22,6 +23,18 @@ const Layout = ({ children }: LayoutProps) => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { currentSettings } = useSettings();
|
const { currentSettings } = useSettings();
|
||||||
const { setLocale } = useLocale();
|
const { setLocale } = useLocale();
|
||||||
|
const { data: requestResponse, mutate: revalidateRequestsCount } = useSWR(
|
||||||
|
'/api/v1/request/count',
|
||||||
|
{
|
||||||
|
revalidateOnMount: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const { data: issueResponse, mutate: revalidateIssueCount } = useSWR(
|
||||||
|
'/api/v1/issue/count',
|
||||||
|
{
|
||||||
|
revalidateOnMount: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (setLocale && user) {
|
if (setLocale && user) {
|
||||||
@@ -55,10 +68,21 @@ const Layout = ({ children }: LayoutProps) => {
|
|||||||
<div className="absolute top-0 h-64 w-full bg-gradient-to-bl from-gray-800 to-gray-900">
|
<div className="absolute top-0 h-64 w-full bg-gradient-to-bl from-gray-800 to-gray-900">
|
||||||
<div className="relative inset-0 h-full w-full bg-gradient-to-t from-gray-900 to-transparent" />
|
<div className="relative inset-0 h-full w-full bg-gradient-to-t from-gray-900 to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
|
<Sidebar
|
||||||
<Sidebar open={isSidebarOpen} setClosed={() => setSidebarOpen(false)} />
|
open={isSidebarOpen}
|
||||||
|
setClosed={() => setSidebarOpen(false)}
|
||||||
|
pendingRequestsCount={requestResponse?.pending ?? 0}
|
||||||
|
openIssuesCount={issueResponse?.open ?? 0}
|
||||||
|
revalidateIssueCount={() => revalidateIssueCount()}
|
||||||
|
revalidateRequestsCount={() => revalidateRequestsCount()}
|
||||||
|
/>
|
||||||
<div className="sm:hidden">
|
<div className="sm:hidden">
|
||||||
<MobileMenu />
|
<MobileMenu
|
||||||
|
pendingRequestsCount={requestResponse?.pending ?? 0}
|
||||||
|
openIssuesCount={issueResponse?.open ?? 0}
|
||||||
|
revalidateIssueCount={() => revalidateIssueCount()}
|
||||||
|
revalidateRequestsCount={() => revalidateRequestsCount()}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mb-16 flex w-0 min-w-0 flex-1 flex-col lg:ml-64">
|
<div className="relative mb-16 flex w-0 min-w-0 flex-1 flex-col lg:ml-64">
|
||||||
|
|||||||
@@ -1,63 +1,39 @@
|
|||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import Tooltip from '@app/components/Common/Tooltip';
|
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { InformationCircleIcon } from '@heroicons/react/24/solid';
|
import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline';
|
||||||
import { ApiErrorCode } from '@server/constants/error';
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
import { MediaServerType, ServerType } from '@server/constants/server';
|
import { MediaServerType, ServerType } from '@server/constants/server';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import { FormattedMessage, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
const messages = defineMessages('components.Login', {
|
const messages = defineMessages('components.Login', {
|
||||||
|
loginwithapp: 'Login with {appName}',
|
||||||
username: 'Username',
|
username: 'Username',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
hostname: '{mediaServerName} URL',
|
|
||||||
port: 'Port',
|
|
||||||
enablessl: 'Use SSL',
|
|
||||||
urlBase: 'URL Base',
|
|
||||||
email: 'Email',
|
|
||||||
emailtooltip:
|
|
||||||
'Address does not need to be associated with your {mediaServerName} instance.',
|
|
||||||
validationhostrequired: '{mediaServerName} URL required',
|
|
||||||
validationhostformat: 'Valid URL required',
|
|
||||||
validationemailrequired: 'Email required',
|
|
||||||
validationemailformat: 'Valid email required',
|
|
||||||
validationusernamerequired: 'Username required',
|
validationusernamerequired: 'Username required',
|
||||||
validationpasswordrequired: 'Password required',
|
validationpasswordrequired: 'Password required',
|
||||||
validationservertyperequired: 'Please select a server type',
|
|
||||||
validationHostnameRequired: 'You must provide a valid hostname or IP address',
|
|
||||||
validationPortRequired: 'You must provide a valid port number',
|
|
||||||
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
|
||||||
validationUrlBaseLeadingSlash: 'URL base must have a leading slash',
|
|
||||||
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
|
|
||||||
loginerror: 'Something went wrong while trying to sign in.',
|
loginerror: 'Something went wrong while trying to sign in.',
|
||||||
adminerror: 'You must use an admin account to sign in.',
|
adminerror: 'You must use an admin account to sign in.',
|
||||||
noadminerror: 'No admin user found on the server.',
|
noadminerror: 'No admin user found on the server.',
|
||||||
credentialerror: 'The username or password is incorrect.',
|
credentialerror: 'The username or password is incorrect.',
|
||||||
invalidurlerror: 'Unable to connect to {mediaServerName} server.',
|
invalidurlerror: 'Unable to connect to {mediaServerName} server.',
|
||||||
signingin: 'Signing in…',
|
signingin: 'Signing In…',
|
||||||
signin: 'Sign In',
|
signin: 'Sign In',
|
||||||
initialsigningin: 'Connecting…',
|
|
||||||
initialsignin: 'Connect',
|
|
||||||
forgotpassword: 'Forgot Password?',
|
forgotpassword: 'Forgot Password?',
|
||||||
servertype: 'Server Type',
|
|
||||||
back: 'Go back',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
interface JellyfinLoginProps {
|
interface JellyfinLoginProps {
|
||||||
revalidate: () => void;
|
revalidate: () => void;
|
||||||
initial?: boolean;
|
|
||||||
serverType?: MediaServerType;
|
serverType?: MediaServerType;
|
||||||
onCancel?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||||
revalidate,
|
revalidate,
|
||||||
initial,
|
|
||||||
serverType,
|
serverType,
|
||||||
onCancel,
|
|
||||||
}) => {
|
}) => {
|
||||||
const toasts = useToasts();
|
const toasts = useToasts();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -72,56 +48,29 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
: 'Media Server',
|
: 'Media Server',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (initial) {
|
const LoginSchema = Yup.object().shape({
|
||||||
const LoginSchema = Yup.object().shape({
|
username: Yup.string().required(
|
||||||
hostname: Yup.string().required(
|
intl.formatMessage(messages.validationusernamerequired)
|
||||||
intl.formatMessage(
|
),
|
||||||
messages.validationhostrequired,
|
password: Yup.string(),
|
||||||
mediaServerFormatValues
|
});
|
||||||
)
|
const baseUrl = settings.currentSettings.jellyfinExternalHost
|
||||||
),
|
? settings.currentSettings.jellyfinExternalHost
|
||||||
port: Yup.number().required(
|
: settings.currentSettings.jellyfinHost;
|
||||||
intl.formatMessage(messages.validationPortRequired)
|
const jellyfinForgotPasswordUrl =
|
||||||
),
|
settings.currentSettings.jellyfinForgotPasswordUrl;
|
||||||
urlBase: Yup.string()
|
|
||||||
.test(
|
|
||||||
'leading-slash',
|
|
||||||
intl.formatMessage(messages.validationUrlBaseLeadingSlash),
|
|
||||||
(value) => !value || value.startsWith('/')
|
|
||||||
)
|
|
||||||
.test(
|
|
||||||
'trailing-slash',
|
|
||||||
intl.formatMessage(messages.validationUrlBaseTrailingSlash),
|
|
||||||
(value) => !value || !value.endsWith('/')
|
|
||||||
),
|
|
||||||
email: Yup.string()
|
|
||||||
.email(intl.formatMessage(messages.validationemailformat))
|
|
||||||
.required(intl.formatMessage(messages.validationemailrequired)),
|
|
||||||
username: Yup.string().required(
|
|
||||||
intl.formatMessage(messages.validationusernamerequired)
|
|
||||||
),
|
|
||||||
password: Yup.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
hostname: '',
|
|
||||||
port: 8096,
|
|
||||||
useSsl: false,
|
|
||||||
urlBase: '',
|
|
||||||
email: '',
|
|
||||||
}}
|
}}
|
||||||
validationSchema={LoginSchema}
|
validationSchema={LoginSchema}
|
||||||
|
validateOnBlur={false}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
// Check if serverType is either 'Jellyfin' or 'Emby'
|
|
||||||
// if (serverType !== 'Jellyfin' && serverType !== 'Emby') {
|
|
||||||
// throw new Error('Invalid serverType'); // You can customize the error message
|
|
||||||
// }
|
|
||||||
|
|
||||||
const res = await fetch('/api/v1/auth/jellyfin', {
|
const res = await fetch('/api/v1/auth/jellyfin', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -130,12 +79,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username: values.username,
|
username: values.username,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
hostname: values.hostname,
|
email: values.username,
|
||||||
port: values.port,
|
|
||||||
useSsl: values.useSsl,
|
|
||||||
urlBase: values.urlBase,
|
|
||||||
email: values.email,
|
|
||||||
serverType: serverType,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
||||||
@@ -165,7 +109,6 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
errorMessage = messages.loginerror;
|
errorMessage = messages.loginerror;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
toasts.addToast(
|
toasts.addToast(
|
||||||
intl.formatMessage(errorMessage, mediaServerFormatValues),
|
intl.formatMessage(errorMessage, mediaServerFormatValues),
|
||||||
{
|
{
|
||||||
@@ -178,303 +121,55 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{({
|
{({ errors, touched, isSubmitting, isValid }) => {
|
||||||
errors,
|
return (
|
||||||
touched,
|
<>
|
||||||
values,
|
<Form data-form-type="login">
|
||||||
setFieldValue,
|
<div>
|
||||||
isSubmitting,
|
<h2 className="mb-6 -mt-1 text-center text-lg font-bold text-neutral-200">
|
||||||
isValid,
|
{intl.formatMessage(messages.loginwithapp, {
|
||||||
}) => (
|
appName: mediaServerFormatValues.mediaServerName,
|
||||||
<Form>
|
})}
|
||||||
<div className="sm:border-t sm:border-gray-800">
|
</h2>
|
||||||
<div className="flex flex-col sm:flex-row sm:gap-4">
|
|
||||||
<div className="w-full">
|
<div className="mt-1 mb-4">
|
||||||
<label htmlFor="hostname" className="text-label">
|
<div className="form-input-field">
|
||||||
{intl.formatMessage(
|
|
||||||
messages.hostname,
|
|
||||||
mediaServerFormatValues
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mb-0 sm:mt-0">
|
|
||||||
<div className="flex rounded-md shadow-sm">
|
|
||||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
|
||||||
{values.useSsl ? 'https://' : 'http://'}
|
|
||||||
</span>
|
|
||||||
<Field
|
<Field
|
||||||
id="hostname"
|
id="username"
|
||||||
name="hostname"
|
name="username"
|
||||||
type="text"
|
type="text"
|
||||||
className="rounded-r-only flex-1"
|
placeholder={intl.formatMessage(messages.username)}
|
||||||
placeholder={intl.formatMessage(
|
className="!bg-gray-700/80 placeholder:text-gray-400"
|
||||||
messages.hostname,
|
data-form-type="username"
|
||||||
mediaServerFormatValues
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.hostname && touched.hostname && (
|
{errors.username && touched.username && (
|
||||||
<div className="error">{errors.hostname}</div>
|
<div className="error">{errors.username}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
<div className="mt-1 mb-2">
|
||||||
<label htmlFor="port" className="text-label">
|
<div className="form-input-field">
|
||||||
{intl.formatMessage(messages.port)}
|
<SensitiveInput
|
||||||
</label>
|
as="field"
|
||||||
<div className="mt-1 sm:mt-0">
|
id="password"
|
||||||
<Field
|
name="password"
|
||||||
id="port"
|
type="password"
|
||||||
name="port"
|
autoComplete="current-password"
|
||||||
inputMode="numeric"
|
placeholder={intl.formatMessage(messages.password)}
|
||||||
type="text"
|
className="!bg-gray-700/80 placeholder:text-gray-400"
|
||||||
className="short flex-1"
|
data-form-type="password"
|
||||||
placeholder={intl.formatMessage(messages.port)}
|
data-1pignore="false"
|
||||||
/>
|
data-lpignore="false"
|
||||||
{errors.port && touched.port && (
|
/>
|
||||||
<div className="error">{errors.port}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label htmlFor="useSsl" className="text-label mt-2">
|
|
||||||
{intl.formatMessage(messages.enablessl)}
|
|
||||||
</label>
|
|
||||||
<div className="mt-1 mb-2 sm:col-span-2">
|
|
||||||
<div className="flex rounded-md shadow-sm">
|
|
||||||
<Field
|
|
||||||
id="useSsl"
|
|
||||||
name="useSsl"
|
|
||||||
type="checkbox"
|
|
||||||
onChange={() => {
|
|
||||||
setFieldValue('useSsl', !values.useSsl);
|
|
||||||
setFieldValue('port', values.useSsl ? 8096 : 443);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label htmlFor="urlBase" className="text-label mt-1">
|
|
||||||
{intl.formatMessage(messages.urlBase)}
|
|
||||||
</label>
|
|
||||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
|
||||||
<div className="flex rounded-md shadow-sm">
|
|
||||||
<Field
|
|
||||||
type="text"
|
|
||||||
inputMode="url"
|
|
||||||
id="urlBase"
|
|
||||||
name="urlBase"
|
|
||||||
placeholder={intl.formatMessage(messages.urlBase)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.urlBase && touched.urlBase && (
|
|
||||||
<div className="error">{errors.urlBase}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<label
|
|
||||||
htmlFor="email"
|
|
||||||
className="text-label inline-flex gap-1 align-middle"
|
|
||||||
>
|
|
||||||
{intl.formatMessage(messages.email)}
|
|
||||||
<span className="label-tip">
|
|
||||||
<Tooltip
|
|
||||||
content={intl.formatMessage(
|
|
||||||
messages.emailtooltip,
|
|
||||||
mediaServerFormatValues
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="tooltip-trigger">
|
|
||||||
<InformationCircleIcon className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div className="mt-1 sm:col-span-2 sm:mb-2 sm:mt-0">
|
|
||||||
<div className="flex rounded-md shadow-sm">
|
|
||||||
<Field
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="text"
|
|
||||||
placeholder={intl.formatMessage(messages.email)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.email && touched.email && (
|
|
||||||
<div className="error">{errors.email}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<label htmlFor="username" className="text-label">
|
|
||||||
{intl.formatMessage(messages.username)}
|
|
||||||
</label>
|
|
||||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
|
||||||
<div className="flex rounded-md shadow-sm">
|
|
||||||
<Field
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
type="text"
|
|
||||||
placeholder={intl.formatMessage(messages.username)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.username && touched.username && (
|
|
||||||
<div className="error">{errors.username}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<label htmlFor="password" className="text-label">
|
|
||||||
{intl.formatMessage(messages.password)}
|
|
||||||
</label>
|
|
||||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
|
||||||
<div className="flexrounded-md shadow-sm">
|
|
||||||
<Field
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
placeholder={intl.formatMessage(messages.password)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.password && touched.password && (
|
|
||||||
<div className="error">{errors.password}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-8 border-t border-gray-700 pt-5">
|
|
||||||
<div className="flex flex-row-reverse justify-between">
|
|
||||||
<span className="inline-flex rounded-md shadow-sm">
|
|
||||||
<Button
|
|
||||||
buttonType="primary"
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting || !isValid}
|
|
||||||
>
|
|
||||||
{isSubmitting
|
|
||||||
? intl.formatMessage(messages.signingin)
|
|
||||||
: intl.formatMessage(messages.signin)}
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
{onCancel && (
|
|
||||||
<span className="inline-flex rounded-md shadow-sm">
|
|
||||||
<Button buttonType="default" onClick={() => onCancel()}>
|
|
||||||
<FormattedMessage {...messages.back} />
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</Formik>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const LoginSchema = Yup.object().shape({
|
|
||||||
username: Yup.string().required(
|
|
||||||
intl.formatMessage(messages.validationusernamerequired)
|
|
||||||
),
|
|
||||||
password: Yup.string(),
|
|
||||||
});
|
|
||||||
const baseUrl = settings.currentSettings.jellyfinExternalHost
|
|
||||||
? settings.currentSettings.jellyfinExternalHost
|
|
||||||
: settings.currentSettings.jellyfinHost;
|
|
||||||
const jellyfinForgotPasswordUrl =
|
|
||||||
settings.currentSettings.jellyfinForgotPasswordUrl;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Formik
|
|
||||||
initialValues={{
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
}}
|
|
||||||
validationSchema={LoginSchema}
|
|
||||||
onSubmit={async (values) => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/v1/auth/jellyfin', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: values.username,
|
|
||||||
password: values.password,
|
|
||||||
email: values.username,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
|
||||||
} catch (e) {
|
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
let errorMessage = null;
|
|
||||||
switch (errorData?.message) {
|
|
||||||
case ApiErrorCode.InvalidUrl:
|
|
||||||
errorMessage = messages.invalidurlerror;
|
|
||||||
break;
|
|
||||||
case ApiErrorCode.InvalidCredentials:
|
|
||||||
errorMessage = messages.credentialerror;
|
|
||||||
break;
|
|
||||||
case ApiErrorCode.NotAdmin:
|
|
||||||
errorMessage = messages.adminerror;
|
|
||||||
break;
|
|
||||||
case ApiErrorCode.NoAdminUser:
|
|
||||||
errorMessage = messages.noadminerror;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
errorMessage = messages.loginerror;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
toasts.addToast(
|
|
||||||
intl.formatMessage(errorMessage, mediaServerFormatValues),
|
|
||||||
{
|
|
||||||
autoDismiss: true,
|
|
||||||
appearance: 'error',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
revalidate();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{({ errors, touched, isSubmitting, isValid }) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Form>
|
|
||||||
<div className="sm:border-t sm:border-gray-800">
|
|
||||||
<label htmlFor="username" className="text-label">
|
|
||||||
{intl.formatMessage(messages.username)}
|
|
||||||
</label>
|
|
||||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
|
||||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
|
||||||
<Field
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
type="text"
|
|
||||||
placeholder={intl.formatMessage(messages.username)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.username && touched.username && (
|
|
||||||
<div className="error">{errors.username}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<label htmlFor="password" className="text-label">
|
<div className="flex">
|
||||||
{intl.formatMessage(messages.password)}
|
|
||||||
</label>
|
|
||||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
|
||||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
|
||||||
<Field
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
placeholder={intl.formatMessage(messages.password)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.password && touched.password && (
|
{errors.password && touched.password && (
|
||||||
<div className="error">{errors.password}</div>
|
<div className="error">{errors.password}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
<div className="flex-grow"></div>
|
||||||
</div>
|
{baseUrl && (
|
||||||
<div className="mt-8 border-t border-gray-700 pt-5">
|
<a
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="inline-flex rounded-md shadow-sm">
|
|
||||||
<Button
|
|
||||||
as="a"
|
|
||||||
buttonType="ghost"
|
|
||||||
href={
|
href={
|
||||||
jellyfinForgotPasswordUrl
|
jellyfinForgotPasswordUrl
|
||||||
? `${jellyfinForgotPasswordUrl}`
|
? `${jellyfinForgotPasswordUrl}`
|
||||||
@@ -485,31 +180,35 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
: ''
|
: ''
|
||||||
}forgotpassword.html`
|
}forgotpassword.html`
|
||||||
}
|
}
|
||||||
|
className="pt-2 text-sm text-indigo-500 hover:text-indigo-400"
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.forgotpassword)}
|
{intl.formatMessage(messages.forgotpassword)}
|
||||||
</Button>
|
</a>
|
||||||
</span>
|
)}
|
||||||
<span className="inline-flex rounded-md shadow-sm">
|
|
||||||
<Button
|
|
||||||
buttonType="primary"
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting || !isValid}
|
|
||||||
>
|
|
||||||
{isSubmitting
|
|
||||||
? intl.formatMessage(messages.signingin)
|
|
||||||
: intl.formatMessage(messages.signin)}
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</div>
|
||||||
</>
|
|
||||||
);
|
<Button
|
||||||
}}
|
buttonType="primary"
|
||||||
</Formik>
|
type="submit"
|
||||||
</div>
|
disabled={isSubmitting || !isValid}
|
||||||
);
|
className="mt-2 w-full shadow-sm"
|
||||||
}
|
>
|
||||||
|
<ArrowLeftOnRectangleIcon />
|
||||||
|
<span>
|
||||||
|
{isSubmitting
|
||||||
|
? intl.formatMessage(messages.signingin)
|
||||||
|
: intl.formatMessage(messages.signin)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Formik>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default JellyfinLogin;
|
export default JellyfinLogin;
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import Button from '@app/components/Common/Button';
|
|||||||
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import {
|
import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline';
|
||||||
ArrowLeftOnRectangleIcon,
|
|
||||||
LifebuoyIcon,
|
|
||||||
} from '@heroicons/react/24/outline';
|
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -13,6 +10,7 @@ import { useIntl } from 'react-intl';
|
|||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
const messages = defineMessages('components.Login', {
|
const messages = defineMessages('components.Login', {
|
||||||
|
loginwithapp: 'Login with {appName}',
|
||||||
username: 'Username',
|
username: 'Username',
|
||||||
email: 'Email Address',
|
email: 'Email Address',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
@@ -53,6 +51,7 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
|
|||||||
password: '',
|
password: '',
|
||||||
}}
|
}}
|
||||||
validationSchema={LoginSchema}
|
validationSchema={LoginSchema}
|
||||||
|
validateOnBlur={false}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/auth/local', {
|
const res = await fetch('/api/v1/auth/local', {
|
||||||
@@ -76,21 +75,27 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
|
|||||||
{({ errors, touched, isSubmitting, isValid }) => {
|
{({ errors, touched, isSubmitting, isValid }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form>
|
<Form data-form-type="login">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="text-label">
|
<h2 className="mb-6 -mt-1 text-center text-lg font-bold text-neutral-200">
|
||||||
{intl.formatMessage(messages.email) +
|
{intl.formatMessage(messages.loginwithapp, {
|
||||||
' / ' +
|
appName: settings.currentSettings.applicationTitle,
|
||||||
intl.formatMessage(messages.username)}
|
})}
|
||||||
</label>
|
</h2>
|
||||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
|
||||||
|
<div className="mt-1 mb-4">
|
||||||
<div className="form-input-field">
|
<div className="form-input-field">
|
||||||
<Field
|
<Field
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
|
placeholder={`${intl.formatMessage(
|
||||||
|
messages.email
|
||||||
|
)} / ${intl.formatMessage(messages.username)}`}
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="email"
|
inputMode="email"
|
||||||
data-testid="email"
|
data-testid="email"
|
||||||
|
data-form-type="username,email"
|
||||||
|
className="!bg-gray-700/80 placeholder:text-gray-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.email &&
|
{errors.email &&
|
||||||
@@ -99,25 +104,38 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
|
|||||||
<div className="error">{errors.email}</div>
|
<div className="error">{errors.email}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<label htmlFor="password" className="text-label">
|
<div className="mt-1 mb-2">
|
||||||
{intl.formatMessage(messages.password)}
|
|
||||||
</label>
|
|
||||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
|
||||||
<div className="form-input-field">
|
<div className="form-input-field">
|
||||||
<SensitiveInput
|
<SensitiveInput
|
||||||
as="field"
|
as="field"
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
|
placeholder={intl.formatMessage(messages.password)}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
data-testid="password"
|
data-testid="password"
|
||||||
|
data-form-type="password"
|
||||||
|
className="!bg-gray-700/80 placeholder:text-gray-400"
|
||||||
|
data-1pignore="false"
|
||||||
|
data-lpignore="false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.password &&
|
<div className="flex">
|
||||||
touched.password &&
|
{errors.password &&
|
||||||
typeof errors.password === 'string' && (
|
touched.password &&
|
||||||
<div className="error">{errors.password}</div>
|
typeof errors.password === 'string' && (
|
||||||
|
<div className="error">{errors.password}</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-grow"></div>
|
||||||
|
{passwordResetEnabled && (
|
||||||
|
<Link
|
||||||
|
href="/resetpassword"
|
||||||
|
className="pt-2 text-sm text-indigo-500 hover:text-indigo-400"
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.forgotpassword)}
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{loginError && (
|
{loginError && (
|
||||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||||
@@ -125,37 +143,21 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8 border-t border-gray-700 pt-5">
|
|
||||||
<div className="flex flex-row-reverse justify-between">
|
<Button
|
||||||
<span className="inline-flex rounded-md shadow-sm">
|
buttonType="primary"
|
||||||
<Button
|
type="submit"
|
||||||
buttonType="primary"
|
disabled={isSubmitting || !isValid}
|
||||||
type="submit"
|
data-testid="local-signin-button"
|
||||||
disabled={isSubmitting || !isValid}
|
className="mt-2 w-full shadow-sm"
|
||||||
data-testid="local-signin-button"
|
>
|
||||||
>
|
<ArrowLeftOnRectangleIcon />
|
||||||
<ArrowLeftOnRectangleIcon />
|
<span>
|
||||||
<span>
|
{isSubmitting
|
||||||
{isSubmitting
|
? intl.formatMessage(messages.signingin)
|
||||||
? intl.formatMessage(messages.signingin)
|
: intl.formatMessage(messages.signin)}
|
||||||
: intl.formatMessage(messages.signin)}
|
</span>
|
||||||
</span>
|
</Button>
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
{passwordResetEnabled && (
|
|
||||||
<span className="inline-flex rounded-md shadow-sm">
|
|
||||||
<Link href="/resetpassword" passHref legacyBehavior>
|
|
||||||
<Button as="a" buttonType="ghost">
|
|
||||||
<LifebuoyIcon />
|
|
||||||
<span>
|
|
||||||
{intl.formatMessage(messages.forgotpassword)}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Form>
|
</Form>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
62
src/components/Login/PlexLoginButton.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import PlexIcon from '@app/assets/services/plex.svg';
|
||||||
|
import Button from '@app/components/Common/Button';
|
||||||
|
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
|
||||||
|
import usePlexLogin from '@app/hooks/usePlexLogin';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
const messages = defineMessages('components.Login', {
|
||||||
|
loginwithapp: 'Login with {appName}',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface PlexLoginButtonProps {
|
||||||
|
onAuthToken: (authToken: string) => void;
|
||||||
|
isProcessing?: boolean;
|
||||||
|
onError?: (message: string) => void;
|
||||||
|
large?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlexLoginButton = ({
|
||||||
|
onAuthToken,
|
||||||
|
onError,
|
||||||
|
isProcessing,
|
||||||
|
large,
|
||||||
|
}: PlexLoginButtonProps) => {
|
||||||
|
const { loading, login } = usePlexLogin({ onAuthToken, onError });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className="relative flex-1 border-[#cc7b19] bg-[rgba(204,123,25,0.3)] hover:border-[#cc7b19] hover:bg-[rgba(204,123,25,0.7)] disabled:opacity-50"
|
||||||
|
onClick={login}
|
||||||
|
disabled={loading || isProcessing}
|
||||||
|
data-testid="plex-login-button"
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute right-0 mr-4 h-4 w-4">
|
||||||
|
<SmallLoadingSpinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{large ? (
|
||||||
|
<FormattedMessage
|
||||||
|
{...messages.loginwithapp}
|
||||||
|
values={{
|
||||||
|
appName: <PlexIcon className="mt-[2px] ml-[0.35em] w-8" />,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(chunks) => (
|
||||||
|
<>
|
||||||
|
{chunks.map((c) =>
|
||||||
|
typeof c === 'string' ? <span>{c}</span> : c
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FormattedMessage>
|
||||||
|
) : (
|
||||||
|
<PlexIcon className="w-8" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlexLoginButton;
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import Accordion from '@app/components/Common/Accordion';
|
import EmbyLogo from '@app/assets/services/emby-icon-only.svg';
|
||||||
|
import JellyfinLogo from '@app/assets/services/jellyfin-icon.svg';
|
||||||
|
import PlexLogo from '@app/assets/services/plex.svg';
|
||||||
|
import Button from '@app/components/Common/Button';
|
||||||
import ImageFader from '@app/components/Common/ImageFader';
|
import ImageFader from '@app/components/Common/ImageFader';
|
||||||
import PageTitle from '@app/components/Common/PageTitle';
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
import LanguagePicker from '@app/components/Layout/LanguagePicker';
|
import LanguagePicker from '@app/components/Layout/LanguagePicker';
|
||||||
|
import JellyfinLogin from '@app/components/Login/JellyfinLogin';
|
||||||
import LocalLogin from '@app/components/Login/LocalLogin';
|
import LocalLogin from '@app/components/Login/LocalLogin';
|
||||||
import PlexLoginButton from '@app/components/PlexLoginButton';
|
import PlexLoginButton from '@app/components/Login/PlexLoginButton';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { useUser } from '@app/hooks/useUser';
|
import { useUser } from '@app/hooks/useUser';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
@@ -12,10 +16,10 @@ import { XCircleIcon } from '@heroicons/react/24/solid';
|
|||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import { useRouter } from 'next/dist/client/router';
|
import { useRouter } from 'next/dist/client/router';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
import { CSSTransition, SwitchTransition } from 'react-transition-group';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import JellyfinLogin from './JellyfinLogin';
|
|
||||||
|
|
||||||
const messages = defineMessages('components.Login', {
|
const messages = defineMessages('components.Login', {
|
||||||
signin: 'Sign In',
|
signin: 'Sign In',
|
||||||
@@ -23,16 +27,21 @@ const messages = defineMessages('components.Login', {
|
|||||||
signinwithplex: 'Use your Plex account',
|
signinwithplex: 'Use your Plex account',
|
||||||
signinwithjellyfin: 'Use your {mediaServerName} account',
|
signinwithjellyfin: 'Use your {mediaServerName} account',
|
||||||
signinwithoverseerr: 'Use your {applicationTitle} account',
|
signinwithoverseerr: 'Use your {applicationTitle} account',
|
||||||
|
orsigninwith: 'Or sign in with',
|
||||||
});
|
});
|
||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const router = useRouter();
|
||||||
|
const settings = useSettings();
|
||||||
|
const { user, revalidate } = useUser();
|
||||||
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isProcessing, setProcessing] = useState(false);
|
const [isProcessing, setProcessing] = useState(false);
|
||||||
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
|
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
|
||||||
const { user, revalidate } = useUser();
|
const [mediaServerLogin, setMediaServerLogin] = useState(
|
||||||
const router = useRouter();
|
settings.currentSettings.mediaServerLogin
|
||||||
const settings = useSettings();
|
);
|
||||||
|
|
||||||
// Effect that is triggered when the `authToken` comes back from the Plex OAuth
|
// Effect that is triggered when the `authToken` comes back from the Plex OAuth
|
||||||
// We take the token and attempt to sign in. If we get a success message, we will
|
// We take the token and attempt to sign in. If we get a success message, we will
|
||||||
@@ -86,14 +95,73 @@ const Login = () => {
|
|||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mediaServerFormatValues = {
|
const mediaServerName =
|
||||||
mediaServerName:
|
settings.currentSettings.mediaServerType === MediaServerType.PLEX
|
||||||
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
|
? 'Plex'
|
||||||
? 'Jellyfin'
|
: settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
|
||||||
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
? 'Jellyfin'
|
||||||
? 'Emby'
|
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
||||||
: undefined,
|
? 'Emby'
|
||||||
};
|
: undefined;
|
||||||
|
|
||||||
|
const MediaServerLogo =
|
||||||
|
settings.currentSettings.mediaServerType === MediaServerType.PLEX
|
||||||
|
? PlexLogo
|
||||||
|
: settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
|
||||||
|
? JellyfinLogo
|
||||||
|
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
||||||
|
? EmbyLogo
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const isJellyfin =
|
||||||
|
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN ||
|
||||||
|
settings.currentSettings.mediaServerType === MediaServerType.EMBY;
|
||||||
|
const mediaServerLoginRef = useRef<HTMLDivElement>(null);
|
||||||
|
const localLoginRef = useRef<HTMLDivElement>(null);
|
||||||
|
const loginRef = mediaServerLogin ? mediaServerLoginRef : localLoginRef;
|
||||||
|
|
||||||
|
const loginFormVisible =
|
||||||
|
(isJellyfin && settings.currentSettings.mediaServerLogin) ||
|
||||||
|
settings.currentSettings.localLogin;
|
||||||
|
const additionalLoginOptions = [
|
||||||
|
settings.currentSettings.mediaServerLogin &&
|
||||||
|
(settings.currentSettings.mediaServerType === MediaServerType.PLEX ? (
|
||||||
|
<PlexLoginButton
|
||||||
|
key="plex"
|
||||||
|
isProcessing={isProcessing}
|
||||||
|
onAuthToken={(authToken) => setAuthToken(authToken)}
|
||||||
|
large={!isJellyfin && !settings.currentSettings.localLogin}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
settings.currentSettings.localLogin &&
|
||||||
|
(mediaServerLogin ? (
|
||||||
|
<Button
|
||||||
|
key="jellyseerr"
|
||||||
|
data-testid="jellyseerr-login-button"
|
||||||
|
className="flex-1 bg-transparent"
|
||||||
|
onClick={() => setMediaServerLogin(false)}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src="/os_icon.svg"
|
||||||
|
alt={settings.currentSettings.applicationTitle}
|
||||||
|
className="mr-2 h-5"
|
||||||
|
/>
|
||||||
|
<span>{settings.currentSettings.applicationTitle}</span>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
key="mediaserver"
|
||||||
|
data-testid="mediaserver-login-button"
|
||||||
|
className="flex-1 bg-transparent"
|
||||||
|
onClick={() => setMediaServerLogin(true)}
|
||||||
|
>
|
||||||
|
<MediaServerLogo />
|
||||||
|
<span>{mediaServerName}</span>
|
||||||
|
</Button>
|
||||||
|
))
|
||||||
|
)),
|
||||||
|
].filter((o): o is JSX.Element => !!o);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen flex-col bg-gray-900 py-14">
|
<div className="relative flex min-h-screen flex-col bg-gray-900 py-14">
|
||||||
@@ -112,9 +180,6 @@ const Login = () => {
|
|||||||
<div className="relative h-48 w-full max-w-full">
|
<div className="relative h-48 w-full max-w-full">
|
||||||
<Image src="/logo_stacked.svg" alt="Logo" fill />
|
<Image src="/logo_stacked.svg" alt="Logo" fill />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mt-12 text-center text-3xl font-extrabold leading-9 text-gray-100">
|
|
||||||
{intl.formatMessage(messages.signinheader)}
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="relative z-50 mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
<div className="relative z-50 mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
<div
|
<div
|
||||||
@@ -145,65 +210,71 @@ const Login = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
<Accordion single atLeastOne>
|
<div className="px-10 py-8">
|
||||||
{({ openIndexes, handleClick, AccordionContent }) => (
|
<SwitchTransition mode="out-in">
|
||||||
<>
|
<CSSTransition
|
||||||
<button
|
key={mediaServerLogin ? 'ms' : 'local'}
|
||||||
className={`w-full cursor-default bg-gray-800 bg-opacity-70 py-2 text-center text-sm font-bold text-gray-400 transition-colors duration-200 focus:outline-none sm:rounded-t-lg ${
|
nodeRef={loginRef}
|
||||||
openIndexes.includes(0) && 'text-indigo-500'
|
addEndListener={(done) => {
|
||||||
} ${
|
loginRef.current?.addEventListener(
|
||||||
settings.currentSettings.localLogin &&
|
'transitionend',
|
||||||
'hover:cursor-pointer hover:bg-gray-700'
|
done,
|
||||||
}`}
|
false
|
||||||
onClick={() => handleClick(0)}
|
);
|
||||||
disabled={!settings.currentSettings.localLogin}
|
}}
|
||||||
>
|
onEntered={() => {
|
||||||
{settings.currentSettings.mediaServerType ==
|
document
|
||||||
MediaServerType.PLEX
|
.querySelector<HTMLInputElement>('#email, #username')
|
||||||
? intl.formatMessage(messages.signinwithplex)
|
?.focus();
|
||||||
: intl.formatMessage(
|
}}
|
||||||
messages.signinwithjellyfin,
|
classNames={{
|
||||||
mediaServerFormatValues
|
appear: 'opacity-0',
|
||||||
)}
|
appearActive: 'transition-opacity duration-500 opacity-100',
|
||||||
</button>
|
enter: 'opacity-0',
|
||||||
<AccordionContent isOpen={openIndexes.includes(0)}>
|
enterActive: 'transition-opacity duration-500 opacity-100',
|
||||||
<div className="px-10 py-8">
|
exitActive: 'transition-opacity duration-0 opacity-0',
|
||||||
{settings.currentSettings.mediaServerType ==
|
}}
|
||||||
MediaServerType.PLEX ? (
|
>
|
||||||
<PlexLoginButton
|
<div ref={loginRef} className="button-container">
|
||||||
isProcessing={isProcessing}
|
{isJellyfin &&
|
||||||
onAuthToken={(authToken) => setAuthToken(authToken)}
|
(mediaServerLogin ||
|
||||||
/>
|
!settings.currentSettings.localLogin) ? (
|
||||||
) : (
|
<JellyfinLogin
|
||||||
<JellyfinLogin revalidate={revalidate} />
|
serverType={settings.currentSettings.mediaServerType}
|
||||||
)}
|
revalidate={revalidate}
|
||||||
</div>
|
/>
|
||||||
</AccordionContent>
|
) : (
|
||||||
{settings.currentSettings.localLogin && (
|
settings.currentSettings.localLogin && (
|
||||||
<div>
|
<LocalLogin revalidate={revalidate} />
|
||||||
<button
|
)
|
||||||
className={`w-full cursor-default bg-gray-800 bg-opacity-70 py-2 text-center text-sm font-bold text-gray-400 transition-colors duration-200 hover:cursor-pointer hover:bg-gray-700 focus:outline-none ${
|
)}
|
||||||
openIndexes.includes(1)
|
</div>
|
||||||
? 'text-indigo-500'
|
</CSSTransition>
|
||||||
: 'sm:rounded-b-lg'
|
</SwitchTransition>
|
||||||
}`}
|
|
||||||
onClick={() => handleClick(1)}
|
{additionalLoginOptions.length > 0 &&
|
||||||
>
|
(loginFormVisible ? (
|
||||||
{intl.formatMessage(messages.signinwithoverseerr, {
|
<div className="flex items-center py-5">
|
||||||
applicationTitle:
|
<div className="flex-grow border-t border-gray-600"></div>
|
||||||
settings.currentSettings.applicationTitle,
|
<span className="mx-2 flex-shrink text-sm text-gray-400">
|
||||||
})}
|
{intl.formatMessage(messages.orsigninwith)}
|
||||||
</button>
|
</span>
|
||||||
<AccordionContent isOpen={openIndexes.includes(1)}>
|
<div className="flex-grow border-t border-gray-600"></div>
|
||||||
<div className="px-10 py-8">
|
</div>
|
||||||
<LocalLogin revalidate={revalidate} />
|
) : (
|
||||||
</div>
|
<h2 className="mb-6 text-center text-lg font-bold text-neutral-200">
|
||||||
</AccordionContent>
|
{intl.formatMessage(messages.signinheader)}
|
||||||
</div>
|
</h2>
|
||||||
)}
|
))}
|
||||||
</>
|
|
||||||
)}
|
<div
|
||||||
</Accordion>
|
className={`flex w-full flex-wrap gap-2 ${
|
||||||
|
!loginFormVisible ? 'flex-col' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{additionalLoginOptions}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -122,15 +122,13 @@ const ManageSlideOver = ({
|
|||||||
|
|
||||||
const deleteMediaFile = async () => {
|
const deleteMediaFile = async () => {
|
||||||
if (data.mediaInfo) {
|
if (data.mediaInfo) {
|
||||||
const res1 = await fetch(`/api/v1/media/${data.mediaInfo.id}/file`, {
|
// we don't check if the response is ok here because there may be no file to delete
|
||||||
|
await fetch(`/api/v1/media/${data.mediaInfo.id}/file`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
if (!res1.ok) throw new Error();
|
await fetch(`/api/v1/media/${data.mediaInfo.id}`, {
|
||||||
|
|
||||||
const res2 = await fetch(`/api/v1/media/${data.mediaInfo.id}`, {
|
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
if (!res2.ok) throw new Error();
|
|
||||||
|
|
||||||
revalidate();
|
revalidate();
|
||||||
onClose();
|
onClose();
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const MediaSlider = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
initialSize: 2,
|
initialSize: 2,
|
||||||
|
revalidateFirstPage: false,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import StatusBadge from '@app/components/StatusBadge';
|
|||||||
import useDeepLinks from '@app/hooks/useDeepLinks';
|
import useDeepLinks from '@app/hooks/useDeepLinks';
|
||||||
import useLocale from '@app/hooks/useLocale';
|
import useLocale from '@app/hooks/useLocale';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, UserType, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import ErrorPage from '@app/pages/_error';
|
import ErrorPage from '@app/pages/_error';
|
||||||
import { sortCrewPriority } from '@app/utils/creditHelpers';
|
import { sortCrewPriority } from '@app/utils/creditHelpers';
|
||||||
@@ -505,7 +505,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
src={
|
src={
|
||||||
data.posterPath
|
data.posterPath
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
||||||
: '/images/overseerr_poster_not_found.png'
|
: '/images/jellyseerr_poster_not_found.png'
|
||||||
}
|
}
|
||||||
alt=""
|
alt=""
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
@@ -590,47 +590,48 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
buttonSize={'md'}
|
buttonSize={'md'}
|
||||||
onClick={() => setShowBlacklistModal(true)}
|
onClick={() => setShowBlacklistModal(true)}
|
||||||
>
|
>
|
||||||
<EyeSlashIcon className={'h-3'} />
|
<EyeSlashIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && (
|
{data?.mediaInfo?.status !== MediaStatus.BLACKLISTED &&
|
||||||
<>
|
user?.userType !== UserType.PLEX && (
|
||||||
{toggleWatchlist ? (
|
<>
|
||||||
<Tooltip content={intl.formatMessage(messages.addtowatchlist)}>
|
{toggleWatchlist ? (
|
||||||
<Button
|
<Tooltip
|
||||||
buttonType={'ghost'}
|
content={intl.formatMessage(messages.addtowatchlist)}
|
||||||
className="z-40 mr-2"
|
|
||||||
buttonSize={'md'}
|
|
||||||
onClick={onClickWatchlistBtn}
|
|
||||||
>
|
>
|
||||||
{isUpdating ? (
|
<Button
|
||||||
<Spinner className="h-3" />
|
buttonType={'ghost'}
|
||||||
) : (
|
className="z-40 mr-2"
|
||||||
<StarIcon className={'h-3 text-amber-300'} />
|
buttonSize={'md'}
|
||||||
)}
|
onClick={onClickWatchlistBtn}
|
||||||
</Button>
|
>
|
||||||
</Tooltip>
|
{isUpdating ? (
|
||||||
) : (
|
<Spinner />
|
||||||
<Tooltip
|
) : (
|
||||||
content={intl.formatMessage(messages.removefromwatchlist)}
|
<StarIcon className={'text-amber-300'} />
|
||||||
>
|
)}
|
||||||
<Button
|
</Button>
|
||||||
className="z-40 mr-2"
|
</Tooltip>
|
||||||
buttonSize={'md'}
|
) : (
|
||||||
onClick={onClickDeleteWatchlistBtn}
|
<Tooltip
|
||||||
|
content={intl.formatMessage(messages.removefromwatchlist)}
|
||||||
>
|
>
|
||||||
{isUpdating ? (
|
<Button
|
||||||
<Spinner className="h-3" />
|
className="z-40 mr-2"
|
||||||
) : (
|
buttonSize={'md'}
|
||||||
<MinusCircleIcon className={'h-3'} />
|
onClick={onClickDeleteWatchlistBtn}
|
||||||
)}
|
>
|
||||||
</Button>
|
{isUpdating ? <Spinner /> : <MinusCircleIcon />}
|
||||||
</Tooltip>
|
</Button>
|
||||||
)}
|
</Tooltip>
|
||||||
</>
|
)}
|
||||||
)}
|
</>
|
||||||
<PlayButton links={mediaLinks} />
|
)}
|
||||||
|
<div className="z-20">
|
||||||
|
<PlayButton links={mediaLinks} />
|
||||||
|
</div>
|
||||||
<RequestButton
|
<RequestButton
|
||||||
mediaType="movie"
|
mediaType="movie"
|
||||||
media={data.mediaInfo}
|
media={data.mediaInfo}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ interface PWAHeaderProps {
|
|||||||
applicationTitle?: string;
|
applicationTitle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PWAHeader = ({ applicationTitle = 'Overseerr' }: PWAHeaderProps) => {
|
const PWAHeader = ({ applicationTitle = 'Jellyseerr' }: PWAHeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<link
|
<link
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import globalMessages from '@app/i18n/globalMessages';
|
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
|
||||||
import PlexOAuth from '@app/utils/plex';
|
|
||||||
import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useIntl } from 'react-intl';
|
|
||||||
|
|
||||||
const messages = defineMessages('components.PlexLoginButton', {
|
|
||||||
signinwithplex: 'Sign In',
|
|
||||||
signingin: 'Signing In…',
|
|
||||||
});
|
|
||||||
|
|
||||||
const plexOAuth = new PlexOAuth();
|
|
||||||
|
|
||||||
interface PlexLoginButtonProps {
|
|
||||||
onAuthToken: (authToken: string) => void;
|
|
||||||
isProcessing?: boolean;
|
|
||||||
onError?: (message: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PlexLoginButton = ({
|
|
||||||
onAuthToken,
|
|
||||||
onError,
|
|
||||||
isProcessing,
|
|
||||||
}: PlexLoginButtonProps) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const getPlexLogin = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const authToken = await plexOAuth.login();
|
|
||||||
setLoading(false);
|
|
||||||
onAuthToken(authToken);
|
|
||||||
} catch (e) {
|
|
||||||
if (onError) {
|
|
||||||
onError(e.message);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<span className="block w-full rounded-md shadow-sm">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
plexOAuth.preparePopup();
|
|
||||||
setTimeout(() => getPlexLogin(), 1500);
|
|
||||||
}}
|
|
||||||
disabled={loading || isProcessing}
|
|
||||||
className="plex-button"
|
|
||||||
>
|
|
||||||
<ArrowLeftOnRectangleIcon />
|
|
||||||
<span>
|
|
||||||
{loading
|
|
||||||
? intl.formatMessage(globalMessages.loading)
|
|
||||||
: isProcessing
|
|
||||||
? intl.formatMessage(messages.signingin)
|
|
||||||
: intl.formatMessage(messages.signinwithplex)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PlexLoginButton;
|
|
||||||
@@ -20,6 +20,7 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
import { mutate } from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages('components.RequestBlock', {
|
const messages = defineMessages('components.RequestBlock', {
|
||||||
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
|
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
|
||||||
@@ -59,6 +60,7 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
|
|||||||
|
|
||||||
if (onUpdate) {
|
if (onUpdate) {
|
||||||
onUpdate();
|
onUpdate();
|
||||||
|
mutate('/api/v1/request/count');
|
||||||
}
|
}
|
||||||
setIsUpdating(false);
|
setIsUpdating(false);
|
||||||
};
|
};
|
||||||
@@ -72,6 +74,7 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
|
|||||||
|
|
||||||
if (onUpdate) {
|
if (onUpdate) {
|
||||||
onUpdate();
|
onUpdate();
|
||||||
|
mutate('/api/v1/request/count');
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsUpdating(false);
|
setIsUpdating(false);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type Media from '@server/entity/Media';
|
|||||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
import { mutate } from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages('components.RequestButton', {
|
const messages = defineMessages('components.RequestButton', {
|
||||||
viewrequest: 'View Request',
|
viewrequest: 'View Request',
|
||||||
@@ -101,6 +102,7 @@ const RequestButton = ({
|
|||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
onUpdate();
|
onUpdate();
|
||||||
|
mutate('/api/v1/request/count');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,6 +125,7 @@ const RequestButton = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
onUpdate();
|
onUpdate();
|
||||||
|
mutate('/api/v1/request/count');
|
||||||
};
|
};
|
||||||
|
|
||||||
const buttons: ButtonOption[] = [];
|
const buttons: ButtonOption[] = [];
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
|||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
mutate('/api/v1/media?filter=allavailable&take=20&sort=mediaAdded');
|
mutate('/api/v1/media?filter=allavailable&take=20&sort=mediaAdded');
|
||||||
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
||||||
|
mutate('/api/v1/request/count');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -271,6 +272,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
revalidate();
|
revalidate();
|
||||||
|
mutate('/api/v1/request/count');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -280,6 +282,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
});
|
});
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
||||||
|
mutate('/api/v1/request/count');
|
||||||
};
|
};
|
||||||
|
|
||||||
const retryRequest = async () => {
|
const retryRequest = async () => {
|
||||||
@@ -618,7 +621,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
src={
|
src={
|
||||||
title.posterPath
|
title.posterPath
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||||
: '/images/overseerr_poster_not_found.png'
|
: '/images/jellyseerr_poster_not_found.png'
|
||||||
}
|
}
|
||||||
alt=""
|
alt=""
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ import {
|
|||||||
TrashIcon,
|
TrashIcon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
} from '@heroicons/react/24/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
import { MediaRequestStatus } from '@server/constants/media';
|
import { MediaRequestStatus, MediaType } 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 { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -27,7 +28,7 @@ import { useState } from 'react';
|
|||||||
import { useInView } from 'react-intersection-observer';
|
import { useInView } from 'react-intersection-observer';
|
||||||
import { FormattedRelativeTime, useIntl } from 'react-intl';
|
import { FormattedRelativeTime, useIntl } from 'react-intl';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import useSWR from 'swr';
|
import useSWR, { mutate } from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages('components.RequestList.RequestItem', {
|
const messages = defineMessages('components.RequestList.RequestItem', {
|
||||||
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
|
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
|
||||||
@@ -69,6 +70,7 @@ const RequestItemError = ({
|
|||||||
});
|
});
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
revalidateList();
|
revalidateList();
|
||||||
|
mutate('/api/v1/request/count');
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
|
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
|
||||||
@@ -292,9 +294,16 @@ const RequestItemError = ({
|
|||||||
interface RequestItemProps {
|
interface RequestItemProps {
|
||||||
request: NonFunctionProperties<MediaRequest> & { profileName?: string };
|
request: NonFunctionProperties<MediaRequest> & { profileName?: string };
|
||||||
revalidateList: () => void;
|
revalidateList: () => void;
|
||||||
|
radarrData?: RadarrSettings[];
|
||||||
|
sonarrData?: SonarrSettings[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
const RequestItem = ({
|
||||||
|
request,
|
||||||
|
revalidateList,
|
||||||
|
radarrData,
|
||||||
|
sonarrData,
|
||||||
|
}: RequestItemProps) => {
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const { ref, inView } = useInView({
|
const { ref, inView } = useInView({
|
||||||
triggerOnce: true,
|
triggerOnce: true,
|
||||||
@@ -334,6 +343,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
revalidate();
|
revalidate();
|
||||||
|
mutate('/api/v1/request/count');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -344,10 +354,12 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
revalidateList();
|
revalidateList();
|
||||||
|
mutate('/api/v1/request/count');
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteMediaFile = async () => {
|
const deleteMediaFile = async () => {
|
||||||
if (request.media) {
|
if (request.media) {
|
||||||
|
// we don't check if the response is ok here because there may be no file to delete
|
||||||
await fetch(`/api/v1/media/${request.media.id}/file`, {
|
await fetch(`/api/v1/media/${request.media.id}/file`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
@@ -386,6 +398,23 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
iOSPlexUrl4k: requestData?.media?.iOSPlexUrl4k,
|
iOSPlexUrl4k: requestData?.media?.iOSPlexUrl4k,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const serviceExists = () => {
|
||||||
|
if (title?.mediaInfo) {
|
||||||
|
if (title?.mediaInfo.mediaType === MediaType.MOVIE) {
|
||||||
|
return (
|
||||||
|
radarrData?.find((radarr) => radarr.id === request.serverId) !==
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
sonarrData?.find((sonarr) => sonarr.id === request.serverId) !==
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
if (!title && !error) {
|
if (!title && !error) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -452,7 +481,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
src={
|
src={
|
||||||
title.posterPath
|
title.posterPath
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||||
: '/images/overseerr_poster_not_found.png'
|
: '/images/jellyseerr_poster_not_found.png'
|
||||||
}
|
}
|
||||||
alt=""
|
alt=""
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
@@ -693,28 +722,30 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
)}
|
)}
|
||||||
{requestData.status !== MediaRequestStatus.PENDING &&
|
{requestData.status !== MediaRequestStatus.PENDING &&
|
||||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||||
<>
|
<ConfirmButton
|
||||||
<ConfirmButton
|
onClick={() => deleteRequest()}
|
||||||
onClick={() => deleteRequest()}
|
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
className="w-full"
|
||||||
className="w-full"
|
>
|
||||||
>
|
<TrashIcon />
|
||||||
<TrashIcon />
|
<span>{intl.formatMessage(messages.deleterequest)}</span>
|
||||||
<span>{intl.formatMessage(messages.deleterequest)}</span>
|
</ConfirmButton>
|
||||||
</ConfirmButton>
|
)}
|
||||||
<ConfirmButton
|
{hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||||
onClick={() => deleteMediaFile()}
|
title?.mediaInfo?.serviceId &&
|
||||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
serviceExists() && (
|
||||||
className="w-full"
|
<ConfirmButton
|
||||||
>
|
onClick={() => deleteMediaFile()}
|
||||||
<TrashIcon />
|
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||||
<span>
|
className="w-full"
|
||||||
{intl.formatMessage(messages.removearr, {
|
>
|
||||||
arr: request.type === 'movie' ? 'Radarr' : 'Sonarr',
|
<TrashIcon />
|
||||||
})}
|
<span>
|
||||||
</span>
|
{intl.formatMessage(messages.removearr, {
|
||||||
</ConfirmButton>
|
arr: request.type === 'movie' ? 'Radarr' : 'Sonarr',
|
||||||
</>
|
})}
|
||||||
|
</span>
|
||||||
|
</ConfirmButton>
|
||||||
)}
|
)}
|
||||||
{requestData.status === MediaRequestStatus.PENDING &&
|
{requestData.status === MediaRequestStatus.PENDING &&
|
||||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
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';
|
||||||
|
import { Permission } from '@server/lib/permissions';
|
||||||
|
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
@@ -51,7 +53,7 @@ const RequestList = () => {
|
|||||||
const { user } = useUser({
|
const { user } = useUser({
|
||||||
id: Number(router.query.userId),
|
id: Number(router.query.userId),
|
||||||
});
|
});
|
||||||
const { user: currentUser } = useUser();
|
const { user: currentUser, hasPermission } = 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 [currentSortDirection, setCurrentSortDirection] =
|
const [currentSortDirection, setCurrentSortDirection] =
|
||||||
@@ -62,6 +64,13 @@ const RequestList = () => {
|
|||||||
const pageIndex = page - 1;
|
const pageIndex = page - 1;
|
||||||
const updateQueryParams = useUpdateQueryParams({ page: page.toString() });
|
const updateQueryParams = useUpdateQueryParams({ page: page.toString() });
|
||||||
|
|
||||||
|
const { data: radarrData } = useSWR<RadarrSettings[]>(
|
||||||
|
hasPermission(Permission.ADMIN) ? '/api/v1/settings/radarr' : null
|
||||||
|
);
|
||||||
|
const { data: sonarrData } = useSWR<SonarrSettings[]>(
|
||||||
|
hasPermission(Permission.ADMIN) ? '/api/v1/settings/sonarr' : null
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
error,
|
error,
|
||||||
@@ -245,6 +254,8 @@ const RequestList = () => {
|
|||||||
<RequestItem
|
<RequestItem
|
||||||
request={request}
|
request={request}
|
||||||
revalidateList={() => revalidate()}
|
revalidateList={() => revalidate()}
|
||||||
|
radarrData={radarrData}
|
||||||
|
sonarrData={sonarrData}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import type { Collection } from '@server/models/Collection';
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import useSWR from 'swr';
|
import useSWR, { mutate } from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages('components.RequestModal', {
|
const messages = defineMessages('components.RequestModal', {
|
||||||
requestadmin: 'This request will be approved automatically.',
|
requestadmin: 'This request will be approved automatically.',
|
||||||
@@ -220,6 +220,7 @@ const CollectionRequestModal = ({
|
|||||||
? MediaStatus.UNKNOWN
|
? MediaStatus.UNKNOWN
|
||||||
: MediaStatus.PARTIALLY_AVAILABLE
|
: MediaStatus.PARTIALLY_AVAILABLE
|
||||||
);
|
);
|
||||||
|
mutate('/api/v1/request/count');
|
||||||
}
|
}
|
||||||
|
|
||||||
addToast(
|
addToast(
|
||||||
@@ -239,7 +240,16 @@ const CollectionRequestModal = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsUpdating(false);
|
setIsUpdating(false);
|
||||||
}
|
}
|
||||||
}, [requestOverrides, data, onComplete, addToast, intl, selectedParts, is4k]);
|
}, [
|
||||||
|
requestOverrides,
|
||||||
|
data?.parts,
|
||||||
|
data?.name,
|
||||||
|
onComplete,
|
||||||
|
addToast,
|
||||||
|
intl,
|
||||||
|
selectedParts,
|
||||||
|
is4k,
|
||||||
|
]);
|
||||||
|
|
||||||
const hasAutoApprove = hasPermission(
|
const hasAutoApprove = hasPermission(
|
||||||
[
|
[
|
||||||
@@ -441,7 +451,7 @@ const CollectionRequestModal = ({
|
|||||||
src={
|
src={
|
||||||
part.posterPath
|
part.posterPath
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${part.posterPath}`
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${part.posterPath}`
|
||||||
: '/images/overseerr_poster_not_found.png'
|
: '/images/jellyseerr_poster_not_found.png'
|
||||||
}
|
}
|
||||||
alt=""
|
alt=""
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ const MovieRequestModal = ({
|
|||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
const mediaRequest: MediaRequest = await res.json();
|
const mediaRequest: MediaRequest = await res.json();
|
||||||
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
||||||
|
mutate('/api/v1/request/count');
|
||||||
|
|
||||||
if (mediaRequest) {
|
if (mediaRequest) {
|
||||||
if (onComplete) {
|
if (onComplete) {
|
||||||
@@ -138,7 +139,16 @@ const MovieRequestModal = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsUpdating(false);
|
setIsUpdating(false);
|
||||||
}
|
}
|
||||||
}, [data, onComplete, addToast, requestOverrides, hasPermission, intl, is4k]);
|
}, [
|
||||||
|
requestOverrides,
|
||||||
|
data?.id,
|
||||||
|
data?.title,
|
||||||
|
is4k,
|
||||||
|
onComplete,
|
||||||
|
addToast,
|
||||||
|
intl,
|
||||||
|
hasPermission,
|
||||||
|
]);
|
||||||
|
|
||||||
const cancelRequest = async () => {
|
const cancelRequest = async () => {
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
@@ -150,6 +160,7 @@ const MovieRequestModal = ({
|
|||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
||||||
|
mutate('/api/v1/request/count');
|
||||||
|
|
||||||
if (res.status === 204) {
|
if (res.status === 204) {
|
||||||
if (onComplete) {
|
if (onComplete) {
|
||||||
@@ -197,6 +208,7 @@ const MovieRequestModal = ({
|
|||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
}
|
}
|
||||||
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
||||||
|
mutate('/api/v1/request/count');
|
||||||
|
|
||||||
addToast(
|
addToast(
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ const SearchByNameModal = ({
|
|||||||
<Image
|
<Image
|
||||||
src={
|
src={
|
||||||
item.remotePoster ??
|
item.remotePoster ??
|
||||||
'/images/overseerr_poster_not_found.png'
|
'/images/jellyseerr_poster_not_found.png'
|
||||||
}
|
}
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
className="w-100 h-auto rounded-md"
|
className="w-100 h-auto rounded-md"
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ const TvRequestModal = ({
|
|||||||
|
|
||||||
if (onUpdating) {
|
if (onUpdating) {
|
||||||
onUpdating(true);
|
onUpdating(true);
|
||||||
|
mutate('/api/v1/request/count');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -141,6 +142,7 @@ const TvRequestModal = ({
|
|||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
}
|
}
|
||||||
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
||||||
|
mutate('/api/v1/request/count');
|
||||||
|
|
||||||
addToast(
|
addToast(
|
||||||
<span>
|
<span>
|
||||||
@@ -189,6 +191,7 @@ const TvRequestModal = ({
|
|||||||
|
|
||||||
if (onUpdating) {
|
if (onUpdating) {
|
||||||
onUpdating(true);
|
onUpdating(true);
|
||||||
|
mutate('/api/v1/request/count');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -52,18 +52,21 @@ type SingleVal = {
|
|||||||
type BaseSelectorMultiProps = {
|
type BaseSelectorMultiProps = {
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
isMulti: true;
|
isMulti: true;
|
||||||
|
isDisabled?: boolean;
|
||||||
onChange: (value: MultiValue<SingleVal> | null) => void;
|
onChange: (value: MultiValue<SingleVal> | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BaseSelectorSingleProps = {
|
type BaseSelectorSingleProps = {
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
isMulti?: false;
|
isMulti?: false;
|
||||||
|
isDisabled?: boolean;
|
||||||
onChange: (value: SingleValue<SingleVal> | null) => void;
|
onChange: (value: SingleValue<SingleVal> | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CompanySelector = ({
|
export const CompanySelector = ({
|
||||||
defaultValue,
|
defaultValue,
|
||||||
isMulti,
|
isMulti,
|
||||||
|
isDisabled,
|
||||||
onChange,
|
onChange,
|
||||||
}: BaseSelectorSingleProps | BaseSelectorMultiProps) => {
|
}: BaseSelectorSingleProps | BaseSelectorMultiProps) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -117,6 +120,7 @@ export const CompanySelector = ({
|
|||||||
className="react-select-container"
|
className="react-select-container"
|
||||||
classNamePrefix="react-select"
|
classNamePrefix="react-select"
|
||||||
isMulti={isMulti}
|
isMulti={isMulti}
|
||||||
|
isDisabled={isDisabled}
|
||||||
defaultValue={defaultDataValue}
|
defaultValue={defaultDataValue}
|
||||||
defaultOptions
|
defaultOptions
|
||||||
cacheOptions
|
cacheOptions
|
||||||
@@ -143,6 +147,7 @@ type GenreSelectorProps = (BaseSelectorMultiProps | BaseSelectorSingleProps) & {
|
|||||||
export const GenreSelector = ({
|
export const GenreSelector = ({
|
||||||
isMulti,
|
isMulti,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
|
isDisabled,
|
||||||
onChange,
|
onChange,
|
||||||
type,
|
type,
|
||||||
}: GenreSelectorProps) => {
|
}: GenreSelectorProps) => {
|
||||||
@@ -203,6 +208,7 @@ export const GenreSelector = ({
|
|||||||
defaultOptions
|
defaultOptions
|
||||||
cacheOptions
|
cacheOptions
|
||||||
isMulti={isMulti}
|
isMulti={isMulti}
|
||||||
|
isDisabled={isDisabled}
|
||||||
loadOptions={loadGenreOptions}
|
loadOptions={loadGenreOptions}
|
||||||
placeholder={intl.formatMessage(messages.searchGenres)}
|
placeholder={intl.formatMessage(messages.searchGenres)}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@@ -215,6 +221,7 @@ export const GenreSelector = ({
|
|||||||
|
|
||||||
export const StatusSelector = ({
|
export const StatusSelector = ({
|
||||||
isMulti,
|
isMulti,
|
||||||
|
isDisabled,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
onChange,
|
onChange,
|
||||||
}: BaseSelectorMultiProps | BaseSelectorSingleProps) => {
|
}: BaseSelectorMultiProps | BaseSelectorSingleProps) => {
|
||||||
@@ -272,6 +279,7 @@ export const StatusSelector = ({
|
|||||||
defaultValue={isMulti ? defaultDataValue : defaultDataValue?.[0]}
|
defaultValue={isMulti ? defaultDataValue : defaultDataValue?.[0]}
|
||||||
defaultOptions
|
defaultOptions
|
||||||
isMulti={isMulti}
|
isMulti={isMulti}
|
||||||
|
isDisabled={isDisabled}
|
||||||
loadOptions={loadStatusOptions}
|
loadOptions={loadStatusOptions}
|
||||||
placeholder={intl.formatMessage(messages.searchStatus)}
|
placeholder={intl.formatMessage(messages.searchStatus)}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@@ -284,6 +292,7 @@ export const StatusSelector = ({
|
|||||||
|
|
||||||
export const KeywordSelector = ({
|
export const KeywordSelector = ({
|
||||||
isMulti,
|
isMulti,
|
||||||
|
isDisabled,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
onChange,
|
onChange,
|
||||||
}: BaseSelectorMultiProps | BaseSelectorSingleProps) => {
|
}: BaseSelectorMultiProps | BaseSelectorSingleProps) => {
|
||||||
@@ -341,6 +350,7 @@ export const KeywordSelector = ({
|
|||||||
key={`keyword-select-${defaultDataValue}`}
|
key={`keyword-select-${defaultDataValue}`}
|
||||||
inputId="data"
|
inputId="data"
|
||||||
isMulti={isMulti}
|
isMulti={isMulti}
|
||||||
|
isDisabled={isDisabled}
|
||||||
className="react-select-container"
|
className="react-select-container"
|
||||||
classNamePrefix="react-select"
|
classNamePrefix="react-select"
|
||||||
noOptionsMessage={({ inputValue }) =>
|
noOptionsMessage={({ inputValue }) =>
|
||||||
@@ -551,6 +561,7 @@ export const WatchProviderSelector = ({
|
|||||||
|
|
||||||
export const UserSelector = ({
|
export const UserSelector = ({
|
||||||
isMulti,
|
isMulti,
|
||||||
|
isDisabled,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
onChange,
|
onChange,
|
||||||
}: BaseSelectorMultiProps | BaseSelectorSingleProps) => {
|
}: BaseSelectorMultiProps | BaseSelectorSingleProps) => {
|
||||||
@@ -567,7 +578,10 @@ export const UserSelector = ({
|
|||||||
|
|
||||||
const users = defaultValue.split(',');
|
const users = defaultValue.split(',');
|
||||||
|
|
||||||
const res = await fetch(`/api/v1/user`);
|
const res = await fetch(
|
||||||
|
`/api/v1/user?includeIds=${encodeURIComponent(defaultValue)}`
|
||||||
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error('Network response was not ok');
|
throw new Error('Network response was not ok');
|
||||||
}
|
}
|
||||||
@@ -613,6 +627,7 @@ export const UserSelector = ({
|
|||||||
defaultOptions
|
defaultOptions
|
||||||
cacheOptions
|
cacheOptions
|
||||||
isMulti={isMulti}
|
isMulti={isMulti}
|
||||||
|
isDisabled={isDisabled}
|
||||||
loadOptions={loadUserOptions}
|
loadOptions={loadUserOptions}
|
||||||
placeholder={intl.formatMessage(messages.searchUsers)}
|
placeholder={intl.formatMessage(messages.searchUsers)}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
|
|||||||
@@ -238,6 +238,11 @@ const NotificationsDiscord = () => {
|
|||||||
name="botUsername"
|
name="botUsername"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={settings.currentSettings.applicationTitle}
|
placeholder={settings.currentSettings.applicationTitle}
|
||||||
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.botUsername &&
|
{errors.botUsername &&
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ const NotificationsEmail = () => {
|
|||||||
otherwise: Yup.string().nullable(),
|
otherwise: Yup.string().nullable(),
|
||||||
})
|
})
|
||||||
.matches(
|
.matches(
|
||||||
/-----BEGIN PGP PRIVATE KEY BLOCK-----.+-----END PGP PRIVATE KEY BLOCK-----/s,
|
/-----BEGIN PGP PRIVATE KEY BLOCK-----.+-----END PGP PRIVATE KEY BLOCK-----/,
|
||||||
intl.formatMessage(messages.validationPgpPrivateKey)
|
intl.formatMessage(messages.validationPgpPrivateKey)
|
||||||
),
|
),
|
||||||
pgpPassword: Yup.string().when('pgpPrivateKey', {
|
pgpPassword: Yup.string().when('pgpPrivateKey', {
|
||||||
@@ -221,6 +221,7 @@ const NotificationsEmail = () => {
|
|||||||
requireTls: values.encryption === 'opportunistic',
|
requireTls: values.encryption === 'opportunistic',
|
||||||
authUser: values.authUser,
|
authUser: values.authUser,
|
||||||
authPass: values.authPass,
|
authPass: values.authPass,
|
||||||
|
allowSelfSigned: values.allowSelfSigned,
|
||||||
senderName: values.senderName,
|
senderName: values.senderName,
|
||||||
pgpPrivateKey: values.pgpPrivateKey,
|
pgpPrivateKey: values.pgpPrivateKey,
|
||||||
pgpPassword: values.pgpPassword,
|
pgpPassword: values.pgpPassword,
|
||||||
@@ -295,6 +296,11 @@ const NotificationsEmail = () => {
|
|||||||
name="emailFrom"
|
name="emailFrom"
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="email"
|
inputMode="email"
|
||||||
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.emailFrom &&
|
{errors.emailFrom &&
|
||||||
@@ -316,6 +322,11 @@ const NotificationsEmail = () => {
|
|||||||
name="smtpHost"
|
name="smtpHost"
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="url"
|
inputMode="url"
|
||||||
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.smtpHost &&
|
{errors.smtpHost &&
|
||||||
@@ -337,6 +348,11 @@ const NotificationsEmail = () => {
|
|||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
className="short"
|
className="short"
|
||||||
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
/>
|
/>
|
||||||
{errors.smtpPort &&
|
{errors.smtpPort &&
|
||||||
touched.smtpPort &&
|
touched.smtpPort &&
|
||||||
@@ -390,7 +406,16 @@ const NotificationsEmail = () => {
|
|||||||
</label>
|
</label>
|
||||||
<div className="form-input-area">
|
<div className="form-input-area">
|
||||||
<div className="form-input-field">
|
<div className="form-input-field">
|
||||||
<Field id="authUser" name="authUser" type="text" />
|
<Field
|
||||||
|
id="authUser"
|
||||||
|
name="authUser"
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -400,12 +425,7 @@ const NotificationsEmail = () => {
|
|||||||
</label>
|
</label>
|
||||||
<div className="form-input-area">
|
<div className="form-input-area">
|
||||||
<div className="form-input-field">
|
<div className="form-input-field">
|
||||||
<SensitiveInput
|
<SensitiveInput as="field" id="authPass" name="authPass" />
|
||||||
as="field"
|
|
||||||
id="authPass"
|
|
||||||
name="authPass"
|
|
||||||
autoComplete="one-time-code"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -430,6 +450,11 @@ const NotificationsEmail = () => {
|
|||||||
type="textarea"
|
type="textarea"
|
||||||
rows="10"
|
rows="10"
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.pgpPrivateKey &&
|
{errors.pgpPrivateKey &&
|
||||||
@@ -457,7 +482,11 @@ const NotificationsEmail = () => {
|
|||||||
as="field"
|
as="field"
|
||||||
id="pgpPassword"
|
id="pgpPassword"
|
||||||
name="pgpPassword"
|
name="pgpPassword"
|
||||||
autoComplete="one-time-code"
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.pgpPassword &&
|
{errors.pgpPassword &&
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ const NotificationsTelegram = () => {
|
|||||||
as="field"
|
as="field"
|
||||||
id="botAPI"
|
id="botAPI"
|
||||||
name="botAPI"
|
name="botAPI"
|
||||||
autoComplete="one-time-code"
|
type="text"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.botAPI &&
|
{errors.botAPI &&
|
||||||
@@ -264,7 +264,16 @@ const NotificationsTelegram = () => {
|
|||||||
</label>
|
</label>
|
||||||
<div className="form-input-area">
|
<div className="form-input-area">
|
||||||
<div className="form-input-field">
|
<div className="form-input-field">
|
||||||
<Field id="botUsername" name="botUsername" type="text" />
|
<Field
|
||||||
|
id="botUsername"
|
||||||
|
name="botUsername"
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.botUsername &&
|
{errors.botUsername &&
|
||||||
touched.botUsername &&
|
touched.botUsername &&
|
||||||
@@ -294,7 +303,16 @@ const NotificationsTelegram = () => {
|
|||||||
</label>
|
</label>
|
||||||
<div className="form-input-area">
|
<div className="form-input-area">
|
||||||
<div className="form-input-field">
|
<div className="form-input-field">
|
||||||
<Field id="chatId" name="chatId" type="text" />
|
<Field
|
||||||
|
id="chatId"
|
||||||
|
name="chatId"
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.chatId &&
|
{errors.chatId &&
|
||||||
touched.chatId &&
|
touched.chatId &&
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ import globalMessages from '@app/i18n/globalMessages';
|
|||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import type OverrideRule from '@server/entity/OverrideRule';
|
import type OverrideRule from '@server/entity/OverrideRule';
|
||||||
|
import type {
|
||||||
|
DVRSettings,
|
||||||
|
RadarrSettings,
|
||||||
|
SonarrSettings,
|
||||||
|
} from '@server/lib/settings';
|
||||||
import { Field, Formik } from 'formik';
|
import { Field, Formik } from 'formik';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import Select from 'react-select';
|
import Select from 'react-select';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
@@ -20,6 +26,9 @@ const messages = defineMessages('components.Settings.OverrideRuleModal', {
|
|||||||
createrule: 'New Override Rule',
|
createrule: 'New Override Rule',
|
||||||
editrule: 'Edit Override Rule',
|
editrule: 'Edit Override Rule',
|
||||||
create: 'Create rule',
|
create: 'Create rule',
|
||||||
|
service: 'Service',
|
||||||
|
serviceDescription: 'Apply this rule to the selected service.',
|
||||||
|
selectService: 'Select service',
|
||||||
conditions: 'Conditions',
|
conditions: 'Conditions',
|
||||||
conditionsDescription:
|
conditionsDescription:
|
||||||
'Specifies conditions before applying parameter changes. Each field must be validated for the rules to be applied (AND operation). A field is considered verified if any of its properties match (OR operation).',
|
'Specifies conditions before applying parameter changes. Each field must be validated for the rules to be applied (AND operation). A field is considered verified if any of its properties match (OR operation).',
|
||||||
@@ -49,21 +58,88 @@ type OptionType = {
|
|||||||
interface OverrideRuleModalProps {
|
interface OverrideRuleModalProps {
|
||||||
rule: OverrideRule | null;
|
rule: OverrideRule | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
testResponse: DVRTestResponse;
|
radarrServices: RadarrSettings[];
|
||||||
radarrId?: number;
|
sonarrServices: SonarrSettings[];
|
||||||
sonarrId?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const OverrideRuleModal = ({
|
const OverrideRuleModal = ({
|
||||||
onClose,
|
onClose,
|
||||||
rule,
|
rule,
|
||||||
testResponse,
|
radarrServices,
|
||||||
radarrId,
|
sonarrServices,
|
||||||
sonarrId,
|
|
||||||
}: OverrideRuleModalProps) => {
|
}: OverrideRuleModalProps) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { addToast } = useToasts();
|
const { addToast } = useToasts();
|
||||||
const { currentSettings } = useSettings();
|
const { currentSettings } = useSettings();
|
||||||
|
const [isValidated, setIsValidated] = useState(rule ? true : false);
|
||||||
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
|
const [testResponse, setTestResponse] = useState<DVRTestResponse>({
|
||||||
|
profiles: [],
|
||||||
|
rootFolders: [],
|
||||||
|
tags: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const getServiceInfos = useCallback(
|
||||||
|
async ({
|
||||||
|
hostname,
|
||||||
|
port,
|
||||||
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
|
useSsl = false,
|
||||||
|
}: {
|
||||||
|
hostname: string;
|
||||||
|
port: number;
|
||||||
|
apiKey: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
useSsl?: boolean;
|
||||||
|
}) => {
|
||||||
|
setIsTesting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/settings/sonarr/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
hostname,
|
||||||
|
apiKey,
|
||||||
|
port: Number(port),
|
||||||
|
baseUrl,
|
||||||
|
useSsl,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const data: DVRTestResponse = await res.json();
|
||||||
|
|
||||||
|
setIsValidated(true);
|
||||||
|
setTestResponse(data);
|
||||||
|
} catch (e) {
|
||||||
|
setIsValidated(false);
|
||||||
|
} finally {
|
||||||
|
setIsTesting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let service: DVRSettings | null = null;
|
||||||
|
if (rule?.radarrServiceId !== null && rule?.radarrServiceId !== undefined) {
|
||||||
|
service = radarrServices[rule?.radarrServiceId] || null;
|
||||||
|
}
|
||||||
|
if (rule?.sonarrServiceId !== null && rule?.sonarrServiceId !== undefined) {
|
||||||
|
service = sonarrServices[rule?.sonarrServiceId] || null;
|
||||||
|
}
|
||||||
|
if (service) {
|
||||||
|
getServiceInfos(service);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
getServiceInfos,
|
||||||
|
radarrServices,
|
||||||
|
rule?.radarrServiceId,
|
||||||
|
rule?.sonarrServiceId,
|
||||||
|
sonarrServices,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition
|
<Transition
|
||||||
@@ -79,6 +155,8 @@ const OverrideRuleModal = ({
|
|||||||
>
|
>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
|
radarrServiceId: rule?.radarrServiceId,
|
||||||
|
sonarrServiceId: rule?.sonarrServiceId,
|
||||||
users: rule?.users,
|
users: rule?.users,
|
||||||
genre: rule?.genre,
|
genre: rule?.genre,
|
||||||
language: rule?.language,
|
language: rule?.language,
|
||||||
@@ -97,8 +175,8 @@ const OverrideRuleModal = ({
|
|||||||
profileId: Number(values.profileId) || null,
|
profileId: Number(values.profileId) || null,
|
||||||
rootFolder: values.rootFolder || null,
|
rootFolder: values.rootFolder || null,
|
||||||
tags: values.tags || null,
|
tags: values.tags || null,
|
||||||
radarrServiceId: radarrId,
|
radarrServiceId: values.radarrServiceId,
|
||||||
sonarrServiceId: sonarrId,
|
sonarrServiceId: values.sonarrServiceId,
|
||||||
};
|
};
|
||||||
if (!rule) {
|
if (!rule) {
|
||||||
const res = await fetch('/api/v1/overrideRule', {
|
const res = await fetch('/api/v1/overrideRule', {
|
||||||
@@ -170,6 +248,75 @@ const OverrideRuleModal = ({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-bold leading-8 text-gray-100">
|
||||||
|
{intl.formatMessage(messages.service)}
|
||||||
|
</h3>
|
||||||
|
<p className="description">
|
||||||
|
{intl.formatMessage(messages.serviceDescription)}
|
||||||
|
</p>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="service" className="text-label">
|
||||||
|
{intl.formatMessage(messages.service)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<select
|
||||||
|
id="service"
|
||||||
|
name="service"
|
||||||
|
defaultValue={
|
||||||
|
values.radarrServiceId !== null
|
||||||
|
? `radarr-${values.radarrServiceId}`
|
||||||
|
: `sonarr-${values.sonarrServiceId}`
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
const id = Number(e.target.value.split('-')[1]);
|
||||||
|
if (e.target.value.startsWith('radarr-')) {
|
||||||
|
setFieldValue('radarrServiceId', id);
|
||||||
|
setFieldValue('sonarrServiceId', null);
|
||||||
|
if (radarrServices[id]) {
|
||||||
|
getServiceInfos(radarrServices[id]);
|
||||||
|
}
|
||||||
|
} else if (e.target.value.startsWith('sonarr-')) {
|
||||||
|
setFieldValue('radarrServiceId', null);
|
||||||
|
setFieldValue('sonarrServiceId', id);
|
||||||
|
if (sonarrServices[id]) {
|
||||||
|
getServiceInfos(sonarrServices[id]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setFieldValue('radarrServiceId', null);
|
||||||
|
setFieldValue('sonarrServiceId', null);
|
||||||
|
setIsValidated(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{intl.formatMessage(messages.selectService)}
|
||||||
|
</option>
|
||||||
|
{radarrServices.map((radarr) => (
|
||||||
|
<option
|
||||||
|
key={`radarr-${radarr.id}`}
|
||||||
|
value={`radarr-${radarr.id}`}
|
||||||
|
>
|
||||||
|
{radarr.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{sonarrServices.map((sonarr) => (
|
||||||
|
<option
|
||||||
|
key={`sonarr-${sonarr.id}`}
|
||||||
|
value={`sonarr-${sonarr.id}`}
|
||||||
|
>
|
||||||
|
{sonarr.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{errors.rootFolder &&
|
||||||
|
touched.rootFolder &&
|
||||||
|
typeof errors.rootFolder === 'string' && (
|
||||||
|
<div className="error">{errors.rootFolder}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<h3 className="text-lg font-bold leading-8 text-gray-100">
|
<h3 className="text-lg font-bold leading-8 text-gray-100">
|
||||||
{intl.formatMessage(messages.conditions)}
|
{intl.formatMessage(messages.conditions)}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -184,6 +331,7 @@ const OverrideRuleModal = ({
|
|||||||
<div className="form-input-field">
|
<div className="form-input-field">
|
||||||
<UserSelector
|
<UserSelector
|
||||||
defaultValue={values.users}
|
defaultValue={values.users}
|
||||||
|
isDisabled={!isValidated || isTesting}
|
||||||
isMulti
|
isMulti
|
||||||
onChange={(users) => {
|
onChange={(users) => {
|
||||||
setFieldValue(
|
setFieldValue(
|
||||||
@@ -207,9 +355,10 @@ const OverrideRuleModal = ({
|
|||||||
<div className="form-input-area">
|
<div className="form-input-area">
|
||||||
<div className="form-input-field">
|
<div className="form-input-field">
|
||||||
<GenreSelector
|
<GenreSelector
|
||||||
type={radarrId ? 'movie' : 'tv'}
|
type={values.radarrServiceId ? 'movie' : 'tv'}
|
||||||
defaultValue={values.genre}
|
defaultValue={values.genre}
|
||||||
isMulti
|
isMulti
|
||||||
|
isDisabled={!isValidated || isTesting}
|
||||||
onChange={(genres) => {
|
onChange={(genres) => {
|
||||||
setFieldValue(
|
setFieldValue(
|
||||||
'genre',
|
'genre',
|
||||||
@@ -237,6 +386,7 @@ const OverrideRuleModal = ({
|
|||||||
setFieldValue={(_key, value) => {
|
setFieldValue={(_key, value) => {
|
||||||
setFieldValue('language', value);
|
setFieldValue('language', value);
|
||||||
}}
|
}}
|
||||||
|
isDisabled={!isValidated || isTesting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.language &&
|
{errors.language &&
|
||||||
@@ -255,6 +405,7 @@ const OverrideRuleModal = ({
|
|||||||
<KeywordSelector
|
<KeywordSelector
|
||||||
defaultValue={values.keywords}
|
defaultValue={values.keywords}
|
||||||
isMulti
|
isMulti
|
||||||
|
isDisabled={!isValidated || isTesting}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setFieldValue(
|
setFieldValue(
|
||||||
'keywords',
|
'keywords',
|
||||||
@@ -282,7 +433,12 @@ const OverrideRuleModal = ({
|
|||||||
</label>
|
</label>
|
||||||
<div className="form-input-area">
|
<div className="form-input-area">
|
||||||
<div className="form-input-field">
|
<div className="form-input-field">
|
||||||
<Field as="select" id="rootFolderRule" name="rootFolder">
|
<Field
|
||||||
|
as="select"
|
||||||
|
id="rootFolderRule"
|
||||||
|
name="rootFolder"
|
||||||
|
disabled={!isValidated || isTesting}
|
||||||
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
{intl.formatMessage(messages.selectRootFolder)}
|
{intl.formatMessage(messages.selectRootFolder)}
|
||||||
</option>
|
</option>
|
||||||
@@ -310,7 +466,12 @@ const OverrideRuleModal = ({
|
|||||||
</label>
|
</label>
|
||||||
<div className="form-input-area">
|
<div className="form-input-area">
|
||||||
<div className="form-input-field">
|
<div className="form-input-field">
|
||||||
<Field as="select" id="profileIdRule" name="profileId">
|
<Field
|
||||||
|
as="select"
|
||||||
|
id="profileIdRule"
|
||||||
|
name="profileId"
|
||||||
|
disabled={!isValidated || isTesting}
|
||||||
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
{intl.formatMessage(messages.selectQualityProfile)}
|
{intl.formatMessage(messages.selectQualityProfile)}
|
||||||
</option>
|
</option>
|
||||||
@@ -343,6 +504,7 @@ const OverrideRuleModal = ({
|
|||||||
value: tag.id,
|
value: tag.id,
|
||||||
}))}
|
}))}
|
||||||
isMulti
|
isMulti
|
||||||
|
isDisabled={!isValidated || isTesting}
|
||||||
placeholder={intl.formatMessage(messages.selecttags)}
|
placeholder={intl.formatMessage(messages.selecttags)}
|
||||||
className="react-select-container"
|
className="react-select-container"
|
||||||
classNamePrefix="react-select"
|
classNamePrefix="react-select"
|
||||||
|
|||||||
@@ -1,267 +0,0 @@
|
|||||||
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
|
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
|
||||||
import { PencilIcon, TrashIcon } from '@heroicons/react/24/solid';
|
|
||||||
import type { TmdbGenre } from '@server/api/themoviedb/interfaces';
|
|
||||||
import type OverrideRule from '@server/entity/OverrideRule';
|
|
||||||
import type { User } from '@server/entity/User';
|
|
||||||
import type {
|
|
||||||
Language,
|
|
||||||
RadarrSettings,
|
|
||||||
SonarrSettings,
|
|
||||||
} from '@server/lib/settings';
|
|
||||||
import type { Keyword } from '@server/models/common';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useIntl } from 'react-intl';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
|
|
||||||
const messages = defineMessages('components.Settings.OverrideRuleTile', {
|
|
||||||
qualityprofile: 'Quality Profile',
|
|
||||||
rootfolder: 'Root Folder',
|
|
||||||
tags: 'Tags',
|
|
||||||
users: 'Users',
|
|
||||||
genre: 'Genre',
|
|
||||||
language: 'Language',
|
|
||||||
keywords: 'Keywords',
|
|
||||||
conditions: 'Conditions',
|
|
||||||
settings: 'Settings',
|
|
||||||
});
|
|
||||||
|
|
||||||
interface OverrideRuleTileProps {
|
|
||||||
rules: OverrideRule[];
|
|
||||||
setOverrideRuleModal: ({
|
|
||||||
open,
|
|
||||||
rule,
|
|
||||||
testResponse,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
rule: OverrideRule | null;
|
|
||||||
testResponse: DVRTestResponse;
|
|
||||||
}) => void;
|
|
||||||
testResponse: DVRTestResponse;
|
|
||||||
radarr?: RadarrSettings | null;
|
|
||||||
sonarr?: SonarrSettings | null;
|
|
||||||
revalidate: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const OverrideRuleTile = ({
|
|
||||||
rules,
|
|
||||||
setOverrideRuleModal,
|
|
||||||
testResponse,
|
|
||||||
radarr,
|
|
||||||
sonarr,
|
|
||||||
revalidate,
|
|
||||||
}: OverrideRuleTileProps) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const [users, setUsers] = useState<User[] | null>(null);
|
|
||||||
const [keywords, setKeywords] = useState<Keyword[] | null>(null);
|
|
||||||
const { data: languages } = useSWR<Language[]>('/api/v1/languages');
|
|
||||||
const { data: genres } = useSWR<TmdbGenre[]>('/api/v1/genres/movie');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
const keywords = await Promise.all(
|
|
||||||
rules
|
|
||||||
.map((rule) => rule.keywords?.split(','))
|
|
||||||
.flat()
|
|
||||||
.filter((keywordId) => keywordId)
|
|
||||||
.map(async (keywordId) => {
|
|
||||||
const res = await fetch(`/api/v1/keyword/${keywordId}`);
|
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
const keyword: Keyword = await res.json();
|
|
||||||
return keyword;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
setKeywords(keywords);
|
|
||||||
const users = await Promise.all(
|
|
||||||
rules
|
|
||||||
.map((rule) => rule.users?.split(','))
|
|
||||||
.flat()
|
|
||||||
.filter((userId) => userId)
|
|
||||||
.map(async (userId) => {
|
|
||||||
const res = await fetch(`/api/v1/user/${userId}`);
|
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
const user: User = await res.json();
|
|
||||||
return user;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
setUsers(users);
|
|
||||||
})();
|
|
||||||
}, [rules]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{rules
|
|
||||||
.filter(
|
|
||||||
(rule) =>
|
|
||||||
(rule.radarrServiceId !== null &&
|
|
||||||
rule.radarrServiceId === radarr?.id) ||
|
|
||||||
(rule.sonarrServiceId !== null &&
|
|
||||||
rule.sonarrServiceId === sonarr?.id)
|
|
||||||
)
|
|
||||||
.map((rule) => (
|
|
||||||
<li className="flex h-full flex-col rounded-lg bg-gray-800 text-left shadow ring-1 ring-gray-500">
|
|
||||||
<div className="flex w-full flex-1 items-center justify-between space-x-6 p-6">
|
|
||||||
<div className="flex-1 truncate">
|
|
||||||
<span className="text-lg">
|
|
||||||
{intl.formatMessage(messages.conditions)}
|
|
||||||
</span>
|
|
||||||
{rule.users && (
|
|
||||||
<p className="truncate text-sm leading-5 text-gray-300">
|
|
||||||
<span className="mr-2 font-bold">
|
|
||||||
{intl.formatMessage(messages.users)}
|
|
||||||
</span>
|
|
||||||
<div className="inline-flex gap-2">
|
|
||||||
{rule.users.split(',').map((userId) => {
|
|
||||||
return (
|
|
||||||
<span>
|
|
||||||
{
|
|
||||||
users?.find((user) => user.id === Number(userId))
|
|
||||||
?.displayName
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{rule.genre && (
|
|
||||||
<p className="truncate text-sm leading-5 text-gray-300">
|
|
||||||
<span className="mr-2 font-bold">
|
|
||||||
{intl.formatMessage(messages.genre)}
|
|
||||||
</span>
|
|
||||||
<div className="inline-flex gap-2">
|
|
||||||
{rule.genre.split(',').map((genreId) => (
|
|
||||||
<span>
|
|
||||||
{genres?.find((g) => g.id === Number(genreId))?.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{rule.language && (
|
|
||||||
<p className="truncate text-sm leading-5 text-gray-300">
|
|
||||||
<span className="mr-2 font-bold">
|
|
||||||
{intl.formatMessage(messages.language)}
|
|
||||||
</span>
|
|
||||||
<div className="inline-flex gap-2">
|
|
||||||
{rule.language
|
|
||||||
.split('|')
|
|
||||||
.filter((languageId) => languageId !== 'server')
|
|
||||||
.map((languageId) => {
|
|
||||||
const language = languages?.find(
|
|
||||||
(language) => language.iso_639_1 === languageId
|
|
||||||
);
|
|
||||||
if (!language) return null;
|
|
||||||
const languageName =
|
|
||||||
intl.formatDisplayName(language.iso_639_1, {
|
|
||||||
type: 'language',
|
|
||||||
fallback: 'none',
|
|
||||||
}) ?? language.english_name;
|
|
||||||
return <span>{languageName}</span>;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{rule.keywords && (
|
|
||||||
<p className="truncate text-sm leading-5 text-gray-300">
|
|
||||||
<span className="mr-2 font-bold">
|
|
||||||
{intl.formatMessage(messages.keywords)}
|
|
||||||
</span>
|
|
||||||
<div className="inline-flex gap-2">
|
|
||||||
{rule.keywords.split(',').map((keywordId) => {
|
|
||||||
return (
|
|
||||||
<span>
|
|
||||||
{
|
|
||||||
keywords?.find(
|
|
||||||
(keyword) => keyword.id === Number(keywordId)
|
|
||||||
)?.name
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<span className="text-lg">
|
|
||||||
{intl.formatMessage(messages.settings)}
|
|
||||||
</span>
|
|
||||||
{rule.profileId && (
|
|
||||||
<p className="runcate text-sm leading-5 text-gray-300">
|
|
||||||
<span className="mr-2 font-bold">
|
|
||||||
{intl.formatMessage(messages.qualityprofile)}
|
|
||||||
</span>
|
|
||||||
{
|
|
||||||
testResponse.profiles.find(
|
|
||||||
(profile) => rule.profileId === profile.id
|
|
||||||
)?.name
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{rule.rootFolder && (
|
|
||||||
<p className="truncate text-sm leading-5 text-gray-300">
|
|
||||||
<span className="mr-2 font-bold">
|
|
||||||
{intl.formatMessage(messages.rootfolder)}
|
|
||||||
</span>
|
|
||||||
{rule.rootFolder}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{rule.tags && rule.tags.length > 0 && (
|
|
||||||
<p className="truncate text-sm leading-5 text-gray-300">
|
|
||||||
<span className="mr-2 font-bold">
|
|
||||||
{intl.formatMessage(messages.tags)}
|
|
||||||
</span>
|
|
||||||
<div className="inline-flex gap-2">
|
|
||||||
{rule.tags.split(',').map((tag) => (
|
|
||||||
<span>
|
|
||||||
{
|
|
||||||
testResponse.tags?.find((t) => t.id === Number(tag))
|
|
||||||
?.label
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="border-t border-gray-500">
|
|
||||||
<div className="-mt-px flex">
|
|
||||||
<div className="flex w-0 flex-1 border-r border-gray-500">
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setOverrideRuleModal({ open: true, rule, testResponse })
|
|
||||||
}
|
|
||||||
className="focus:ring-blue relative -mr-px inline-flex w-0 flex-1 items-center justify-center rounded-bl-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
|
|
||||||
>
|
|
||||||
<PencilIcon className="mr-2 h-5 w-5" />
|
|
||||||
<span>{intl.formatMessage(globalMessages.edit)}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="-ml-px flex w-0 flex-1">
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
const res = await fetch(
|
|
||||||
`/api/v1/overrideRule/${rule.id}`,
|
|
||||||
{
|
|
||||||
method: 'DELETE',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
revalidate();
|
|
||||||
}}
|
|
||||||
className="focus:ring-blue relative inline-flex w-0 flex-1 items-center justify-center rounded-br-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
|
|
||||||
>
|
|
||||||
<TrashIcon className="mr-2 h-5 w-5" />
|
|
||||||
<span>{intl.formatMessage(globalMessages.delete)}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default OverrideRuleTile;
|
|
||||||
318
src/components/Settings/OverrideRule/OverrideRuleTiles.tsx
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
|
||||||
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { PencilIcon, TrashIcon } from '@heroicons/react/24/solid';
|
||||||
|
import type { TmdbGenre } from '@server/api/themoviedb/interfaces';
|
||||||
|
import type OverrideRule from '@server/entity/OverrideRule';
|
||||||
|
import type { User } from '@server/entity/User';
|
||||||
|
import type {
|
||||||
|
DVRSettings,
|
||||||
|
Language,
|
||||||
|
RadarrSettings,
|
||||||
|
SonarrSettings,
|
||||||
|
} from '@server/lib/settings';
|
||||||
|
import type { Keyword } from '@server/models/common';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
const messages = defineMessages('components.Settings.OverrideRuleTile', {
|
||||||
|
qualityprofile: 'Quality Profile',
|
||||||
|
rootfolder: 'Root Folder',
|
||||||
|
tags: 'Tags',
|
||||||
|
users: 'Users',
|
||||||
|
genre: 'Genre',
|
||||||
|
language: 'Language',
|
||||||
|
keywords: 'Keywords',
|
||||||
|
conditions: 'Conditions',
|
||||||
|
settings: 'Settings',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface OverrideRuleTilesProps {
|
||||||
|
rules: OverrideRule[];
|
||||||
|
setOverrideRuleModal: ({
|
||||||
|
open,
|
||||||
|
rule,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
rule: OverrideRule | null;
|
||||||
|
}) => void;
|
||||||
|
revalidate: () => void;
|
||||||
|
radarrServices: RadarrSettings[];
|
||||||
|
sonarrServices: SonarrSettings[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const OverrideRuleTiles = ({
|
||||||
|
rules,
|
||||||
|
setOverrideRuleModal,
|
||||||
|
revalidate,
|
||||||
|
radarrServices,
|
||||||
|
sonarrServices,
|
||||||
|
}: OverrideRuleTilesProps) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const [users, setUsers] = useState<User[] | null>(null);
|
||||||
|
const [keywords, setKeywords] = useState<Keyword[] | null>(null);
|
||||||
|
const { data: languages } = useSWR<Language[]>('/api/v1/languages');
|
||||||
|
const { data: genres } = useSWR<TmdbGenre[]>('/api/v1/genres/movie');
|
||||||
|
const [testResponses, setTestResponses] = useState<
|
||||||
|
(DVRTestResponse & { type: string; id: number })[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
const getServiceInfos = useCallback(async () => {
|
||||||
|
const results: (DVRTestResponse & { type: string; id: number })[] = [];
|
||||||
|
const services: DVRSettings[] = [...radarrServices, ...sonarrServices];
|
||||||
|
for (const service of services) {
|
||||||
|
const { hostname, port, apiKey, baseUrl, useSsl = false } = service;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/v1/settings/${
|
||||||
|
radarrServices.includes(service as RadarrSettings)
|
||||||
|
? 'radarr'
|
||||||
|
: 'sonarr'
|
||||||
|
}/test`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
hostname,
|
||||||
|
apiKey,
|
||||||
|
port: Number(port),
|
||||||
|
baseUrl,
|
||||||
|
useSsl,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const data: DVRTestResponse = await res.json();
|
||||||
|
results.push({
|
||||||
|
type: radarrServices.includes(service as RadarrSettings)
|
||||||
|
? 'radarr'
|
||||||
|
: 'sonarr',
|
||||||
|
id: service.id,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
results.push({
|
||||||
|
type: radarrServices.includes(service as RadarrSettings)
|
||||||
|
? 'radarr'
|
||||||
|
: 'sonarr',
|
||||||
|
id: service.id,
|
||||||
|
profiles: [],
|
||||||
|
rootFolders: [],
|
||||||
|
tags: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTestResponses(results);
|
||||||
|
}, [radarrServices, sonarrServices]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getServiceInfos();
|
||||||
|
}, [getServiceInfos]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const keywords = await Promise.all(
|
||||||
|
rules
|
||||||
|
.map((rule) => rule.keywords?.split(','))
|
||||||
|
.flat()
|
||||||
|
.filter((keywordId) => keywordId)
|
||||||
|
.map(async (keywordId) => {
|
||||||
|
const res = await fetch(`/api/v1/keyword/${keywordId}`);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const keyword: Keyword = await res.json();
|
||||||
|
return keyword;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setKeywords(keywords);
|
||||||
|
const allUsersFromRules = rules
|
||||||
|
.map((rule) => rule.users)
|
||||||
|
.filter((users) => users)
|
||||||
|
.join(',');
|
||||||
|
if (allUsersFromRules) {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/v1/user?includeIds=${encodeURIComponent(allUsersFromRules)}`
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const users: User[] = (await res.json()).results;
|
||||||
|
setUsers(users);
|
||||||
|
}
|
||||||
|
setUsers(users);
|
||||||
|
})();
|
||||||
|
}, [rules]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{rules.map((rule) => (
|
||||||
|
<li className="flex h-full flex-col rounded-lg bg-gray-800 text-left shadow ring-1 ring-gray-500">
|
||||||
|
<div className="flex w-full flex-1 items-center justify-between space-x-6 p-6">
|
||||||
|
<div className="flex-1 truncate">
|
||||||
|
<span className="text-lg">
|
||||||
|
{intl.formatMessage(messages.conditions)}
|
||||||
|
</span>
|
||||||
|
{rule.users && (
|
||||||
|
<p className="truncate text-sm leading-5 text-gray-300">
|
||||||
|
<span className="mr-2 font-bold">
|
||||||
|
{intl.formatMessage(messages.users)}
|
||||||
|
</span>
|
||||||
|
<div className="inline-flex gap-2">
|
||||||
|
{rule.users.split(',').map((userId) => {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{
|
||||||
|
users?.find((user) => user.id === Number(userId))
|
||||||
|
?.displayName
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{rule.genre && (
|
||||||
|
<p className="truncate text-sm leading-5 text-gray-300">
|
||||||
|
<span className="mr-2 font-bold">
|
||||||
|
{intl.formatMessage(messages.genre)}
|
||||||
|
</span>
|
||||||
|
<div className="inline-flex gap-2">
|
||||||
|
{rule.genre.split(',').map((genreId) => (
|
||||||
|
<span>
|
||||||
|
{genres?.find((g) => g.id === Number(genreId))?.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{rule.language && (
|
||||||
|
<p className="truncate text-sm leading-5 text-gray-300">
|
||||||
|
<span className="mr-2 font-bold">
|
||||||
|
{intl.formatMessage(messages.language)}
|
||||||
|
</span>
|
||||||
|
<div className="inline-flex gap-2">
|
||||||
|
{rule.language
|
||||||
|
.split('|')
|
||||||
|
.filter((languageId) => languageId !== 'server')
|
||||||
|
.map((languageId) => {
|
||||||
|
const language = languages?.find(
|
||||||
|
(language) => language.iso_639_1 === languageId
|
||||||
|
);
|
||||||
|
if (!language) return null;
|
||||||
|
const languageName =
|
||||||
|
intl.formatDisplayName(language.iso_639_1, {
|
||||||
|
type: 'language',
|
||||||
|
fallback: 'none',
|
||||||
|
}) ?? language.english_name;
|
||||||
|
return <span>{languageName}</span>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{rule.keywords && (
|
||||||
|
<p className="truncate text-sm leading-5 text-gray-300">
|
||||||
|
<span className="mr-2 font-bold">
|
||||||
|
{intl.formatMessage(messages.keywords)}
|
||||||
|
</span>
|
||||||
|
<div className="inline-flex gap-2">
|
||||||
|
{rule.keywords.split(',').map((keywordId) => {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{
|
||||||
|
keywords?.find(
|
||||||
|
(keyword) => keyword.id === Number(keywordId)
|
||||||
|
)?.name
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<span className="text-lg">
|
||||||
|
{intl.formatMessage(messages.settings)}
|
||||||
|
</span>
|
||||||
|
{rule.profileId && (
|
||||||
|
<p className="runcate text-sm leading-5 text-gray-300">
|
||||||
|
<span className="mr-2 font-bold">
|
||||||
|
{intl.formatMessage(messages.qualityprofile)}
|
||||||
|
</span>
|
||||||
|
{testResponses
|
||||||
|
.find(
|
||||||
|
(r) =>
|
||||||
|
(r.id === rule.radarrServiceId &&
|
||||||
|
r.type === 'radarr') ||
|
||||||
|
(r.id === rule.sonarrServiceId && r.type === 'sonarr')
|
||||||
|
)
|
||||||
|
?.profiles.find((profile) => rule.profileId === profile.id)
|
||||||
|
?.name || rule.profileId}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{rule.rootFolder && (
|
||||||
|
<p className="truncate text-sm leading-5 text-gray-300">
|
||||||
|
<span className="mr-2 font-bold">
|
||||||
|
{intl.formatMessage(messages.rootfolder)}
|
||||||
|
</span>
|
||||||
|
{rule.rootFolder}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{rule.tags && rule.tags.length > 0 && (
|
||||||
|
<p className="truncate text-sm leading-5 text-gray-300">
|
||||||
|
<span className="mr-2 font-bold">
|
||||||
|
{intl.formatMessage(messages.tags)}
|
||||||
|
</span>
|
||||||
|
<div className="inline-flex gap-2">
|
||||||
|
{rule.tags.split(',').map((tag) => (
|
||||||
|
<span>
|
||||||
|
{testResponses
|
||||||
|
.find(
|
||||||
|
(r) =>
|
||||||
|
(r.id === rule.radarrServiceId &&
|
||||||
|
r.type === 'radarr') ||
|
||||||
|
(r.id === rule.sonarrServiceId &&
|
||||||
|
r.type === 'sonarr')
|
||||||
|
)
|
||||||
|
?.tags?.find((t) => t.id === Number(tag))?.label ||
|
||||||
|
tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-gray-500">
|
||||||
|
<div className="-mt-px flex">
|
||||||
|
<div className="flex w-0 flex-1 border-r border-gray-500">
|
||||||
|
<button
|
||||||
|
onClick={() => setOverrideRuleModal({ open: true, rule })}
|
||||||
|
className="focus:ring-blue relative -mr-px inline-flex w-0 flex-1 items-center justify-center rounded-bl-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<PencilIcon className="mr-2 h-5 w-5" />
|
||||||
|
<span>{intl.formatMessage(globalMessages.edit)}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="-ml-px flex w-0 flex-1">
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const res = await fetch(`/api/v1/overrideRule/${rule.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
revalidate();
|
||||||
|
}}
|
||||||
|
className="focus:ring-blue relative inline-flex w-0 flex-1 items-center justify-center rounded-br-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<TrashIcon className="mr-2 h-5 w-5" />
|
||||||
|
<span>{intl.formatMessage(globalMessages.delete)}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OverrideRuleTiles;
|
||||||
@@ -1,24 +1,15 @@
|
|||||||
import Button from '@app/components/Common/Button';
|
|
||||||
import Modal from '@app/components/Common/Modal';
|
import Modal from '@app/components/Common/Modal';
|
||||||
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||||
import OverrideRuleTile from '@app/components/Settings/OverrideRule/OverrideRuleTile';
|
import type { RadarrTestResponse } from '@app/components/Settings/SettingsServices';
|
||||||
import type {
|
|
||||||
DVRTestResponse,
|
|
||||||
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 { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import { PlusIcon } from '@heroicons/react/24/solid';
|
|
||||||
import type OverrideRule from '@server/entity/OverrideRule';
|
|
||||||
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
|
|
||||||
import type { RadarrSettings } from '@server/lib/settings';
|
import type { RadarrSettings } from '@server/lib/settings';
|
||||||
import { Field, Formik } from 'formik';
|
import { Field, Formik } from 'formik';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import Select from 'react-select';
|
import Select from 'react-select';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import useSWR from 'swr';
|
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
type OptionType = {
|
type OptionType = {
|
||||||
@@ -79,36 +70,16 @@ const messages = defineMessages('components.Settings.RadarrModal', {
|
|||||||
announced: 'Announced',
|
announced: 'Announced',
|
||||||
inCinemas: 'In Cinemas',
|
inCinemas: 'In Cinemas',
|
||||||
released: 'Released',
|
released: 'Released',
|
||||||
overrideRules: 'Override Rules',
|
|
||||||
addrule: 'New Override Rule',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
interface RadarrModalProps {
|
interface RadarrModalProps {
|
||||||
radarr: RadarrSettings | null;
|
radarr: RadarrSettings | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
overrideRuleModal: { open: boolean; rule: OverrideRule | null };
|
|
||||||
setOverrideRuleModal: ({
|
|
||||||
open,
|
|
||||||
rule,
|
|
||||||
testResponse,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
rule: OverrideRule | null;
|
|
||||||
testResponse: DVRTestResponse;
|
|
||||||
}) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const RadarrModal = ({
|
const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
||||||
onClose,
|
|
||||||
radarr,
|
|
||||||
onSave,
|
|
||||||
overrideRuleModal,
|
|
||||||
setOverrideRuleModal,
|
|
||||||
}: RadarrModalProps) => {
|
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { data: rules, mutate: revalidate } =
|
|
||||||
useSWR<OverrideRuleResultsResponse>('/api/v1/overrideRule');
|
|
||||||
const initialLoad = useRef(false);
|
const initialLoad = useRef(false);
|
||||||
const { addToast } = useToasts();
|
const { addToast } = useToasts();
|
||||||
const [isValidated, setIsValidated] = useState(radarr ? true : false);
|
const [isValidated, setIsValidated] = useState(radarr ? true : false);
|
||||||
@@ -235,10 +206,6 @@ const RadarrModal = ({
|
|||||||
}
|
}
|
||||||
}, [radarr, testConnection]);
|
}, [radarr, testConnection]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
revalidate();
|
|
||||||
}, [overrideRuleModal, revalidate]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition
|
<Transition
|
||||||
as="div"
|
as="div"
|
||||||
@@ -382,7 +349,6 @@ const RadarrModal = ({
|
|||||||
values.is4k ? messages.edit4kradarr : messages.editradarr
|
values.is4k ? messages.edit4kradarr : messages.editradarr
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
backgroundClickable={!overrideRuleModal.open}
|
|
||||||
>
|
>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
@@ -416,6 +382,11 @@ const RadarrModal = ({
|
|||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
type="text"
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setIsValidated(false);
|
setIsValidated(false);
|
||||||
setFieldValue('name', e.target.value);
|
setFieldValue('name', e.target.value);
|
||||||
@@ -509,7 +480,6 @@ const RadarrModal = ({
|
|||||||
as="field"
|
as="field"
|
||||||
id="apiKey"
|
id="apiKey"
|
||||||
name="apiKey"
|
name="apiKey"
|
||||||
autoComplete="one-time-code"
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setIsValidated(false);
|
setIsValidated(false);
|
||||||
setFieldValue('apiKey', e.target.value);
|
setFieldValue('apiKey', e.target.value);
|
||||||
@@ -773,42 +743,6 @@ const RadarrModal = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{radarr && (
|
|
||||||
<>
|
|
||||||
<h3 className="mb-4 text-xl font-bold leading-8 text-gray-100">
|
|
||||||
{intl.formatMessage(messages.overrideRules)}
|
|
||||||
</h3>
|
|
||||||
<ul className="grid gap-x-4 gap-y-8 sm:grid-cols-3 sm:gap-x-6 sm:gap-y-6 lg:grid-cols-2">
|
|
||||||
{rules && (
|
|
||||||
<OverrideRuleTile
|
|
||||||
rules={rules}
|
|
||||||
setOverrideRuleModal={setOverrideRuleModal}
|
|
||||||
testResponse={testResponse}
|
|
||||||
radarr={radarr}
|
|
||||||
revalidate={revalidate}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<li className="min-h-[8rem] rounded-lg border-2 border-dashed border-gray-400 shadow sm:min-h-[11rem]">
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<Button
|
|
||||||
buttonType="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
setOverrideRuleModal({
|
|
||||||
open: true,
|
|
||||||
rule: null,
|
|
||||||
testResponse,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={!isValidated}
|
|
||||||
>
|
|
||||||
<PlusIcon />
|
|
||||||
<span>{intl.formatMessage(messages.addrule)}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import useSWR from 'swr';
|
|||||||
const messages = defineMessages('components.Settings.SettingsLogs', {
|
const messages = defineMessages('components.Settings.SettingsLogs', {
|
||||||
logs: 'Logs',
|
logs: 'Logs',
|
||||||
logsDescription:
|
logsDescription:
|
||||||
'You can also view these logs directly via <code>stdout</code>, or in <code>{appDataPath}/logs/overseerr.log</code>.',
|
'You can also view these logs directly via <code>stdout</code>, or in <code>{appDataPath}/logs/jellyseerr.log</code>.',
|
||||||
time: 'Timestamp',
|
time: 'Timestamp',
|
||||||
level: 'Severity',
|
level: 'Severity',
|
||||||
label: 'Label',
|
label: 'Label',
|
||||||
|
|||||||
@@ -27,12 +27,6 @@ const messages = defineMessages('components.Settings.SettingsNetwork', {
|
|||||||
trustProxy: 'Enable Proxy Support',
|
trustProxy: 'Enable Proxy Support',
|
||||||
trustProxyTip:
|
trustProxyTip:
|
||||||
'Allow Jellyseerr to correctly register client IP addresses behind a proxy',
|
'Allow Jellyseerr to correctly register client IP addresses behind a proxy',
|
||||||
forceIpv4First: 'IPv4 Resolution First',
|
|
||||||
forceIpv4FirstTip:
|
|
||||||
'Force Jellyseerr to resolve IPv4 addresses first instead of IPv6',
|
|
||||||
dnsServers: 'Custom DNS Servers',
|
|
||||||
dnsServersTip:
|
|
||||||
'Comma-separated list of custom DNS servers, e.g. "1.1.1.1,[2606:4700:4700::1111]"',
|
|
||||||
proxyEnabled: 'HTTP(S) Proxy',
|
proxyEnabled: 'HTTP(S) Proxy',
|
||||||
proxyHostname: 'Proxy Hostname',
|
proxyHostname: 'Proxy Hostname',
|
||||||
proxyPort: 'Proxy Port',
|
proxyPort: 'Proxy Port',
|
||||||
@@ -44,9 +38,16 @@ const messages = defineMessages('components.Settings.SettingsNetwork', {
|
|||||||
"Use ',' as a separator, and '*.' as a wildcard for subdomains",
|
"Use ',' as a separator, and '*.' as a wildcard for subdomains",
|
||||||
proxyBypassLocalAddresses: 'Bypass Proxy for Local Addresses',
|
proxyBypassLocalAddresses: 'Bypass Proxy for Local Addresses',
|
||||||
validationProxyPort: 'You must provide a valid port',
|
validationProxyPort: 'You must provide a valid port',
|
||||||
|
advancedNetworkSettings: 'Advanced Network Settings',
|
||||||
|
networkDisclaimer:
|
||||||
|
'Network parameters from your container/system should be used instead of these settings. See the {docs} for more information.',
|
||||||
|
docs: 'documentation',
|
||||||
|
forceIpv4First: 'Force IPv4 Resolution First',
|
||||||
|
forceIpv4FirstTip:
|
||||||
|
'Force Jellyseerr to resolve IPv4 addresses first instead of IPv6',
|
||||||
});
|
});
|
||||||
|
|
||||||
const SettingsMain = () => {
|
const SettingsNetwork = () => {
|
||||||
const { addToast } = useToasts();
|
const { addToast } = useToasts();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const {
|
const {
|
||||||
@@ -89,7 +90,6 @@ const SettingsMain = () => {
|
|||||||
initialValues={{
|
initialValues={{
|
||||||
csrfProtection: data?.csrfProtection,
|
csrfProtection: data?.csrfProtection,
|
||||||
forceIpv4First: data?.forceIpv4First,
|
forceIpv4First: data?.forceIpv4First,
|
||||||
dnsServers: data?.dnsServers,
|
|
||||||
trustProxy: data?.trustProxy,
|
trustProxy: data?.trustProxy,
|
||||||
proxyEnabled: data?.proxy?.enabled,
|
proxyEnabled: data?.proxy?.enabled,
|
||||||
proxyHostname: data?.proxy?.hostname,
|
proxyHostname: data?.proxy?.hostname,
|
||||||
@@ -112,7 +112,6 @@ const SettingsMain = () => {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
csrfProtection: values.csrfProtection,
|
csrfProtection: values.csrfProtection,
|
||||||
forceIpv4First: values.forceIpv4First,
|
forceIpv4First: values.forceIpv4First,
|
||||||
dnsServers: values.dnsServers,
|
|
||||||
trustProxy: values.trustProxy,
|
trustProxy: values.trustProxy,
|
||||||
proxy: {
|
proxy: {
|
||||||
enabled: values.proxyEnabled,
|
enabled: values.proxyEnabled,
|
||||||
@@ -206,55 +205,6 @@ const SettingsMain = () => {
|
|||||||
</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" />
|
|
||||||
<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">
|
|
||||||
<label htmlFor="dnsServers" className="checkbox-label">
|
|
||||||
<span className="mr-2">
|
|
||||||
{intl.formatMessage(messages.dnsServers)}
|
|
||||||
</span>
|
|
||||||
<SettingsBadge badgeType="advanced" className="mr-2" />
|
|
||||||
<SettingsBadge badgeType="restartRequired" />
|
|
||||||
<span className="label-tip">
|
|
||||||
{intl.formatMessage(messages.dnsServersTip)}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div className="form-input-area">
|
|
||||||
<div className="form-input-field">
|
|
||||||
<Field
|
|
||||||
id="dnsServers"
|
|
||||||
name="dnsServers"
|
|
||||||
type="text"
|
|
||||||
inputMode="url"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.dnsServers &&
|
|
||||||
touched.dnsServers &&
|
|
||||||
typeof errors.dnsServers === 'string' && (
|
|
||||||
<div className="error">{errors.dnsServers}</div>
|
|
||||||
)}
|
|
||||||
</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">
|
||||||
@@ -431,6 +381,46 @@ const SettingsMain = () => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<h3 className="heading mt-10">
|
||||||
|
{intl.formatMessage(messages.advancedNetworkSettings)}
|
||||||
|
</h3>
|
||||||
|
<p className="description">
|
||||||
|
{intl.formatMessage(messages.networkDisclaimer, {
|
||||||
|
docs: (
|
||||||
|
<a
|
||||||
|
href="https://docs.jellyseerr.dev/troubleshooting"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-white"
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.docs)}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<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="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">
|
||||||
@@ -458,4 +448,4 @@ const SettingsMain = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SettingsMain;
|
export default SettingsNetwork;
|
||||||
|
|||||||
@@ -872,6 +872,11 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
|
|||||||
id="tautulliPort"
|
id="tautulliPort"
|
||||||
name="tautulliPort"
|
name="tautulliPort"
|
||||||
className="short"
|
className="short"
|
||||||
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
/>
|
/>
|
||||||
{errors.tautulliPort &&
|
{errors.tautulliPort &&
|
||||||
touched.tautulliPort &&
|
touched.tautulliPort &&
|
||||||
@@ -909,6 +914,11 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
|
|||||||
inputMode="url"
|
inputMode="url"
|
||||||
id="tautulliUrlBase"
|
id="tautulliUrlBase"
|
||||||
name="tautulliUrlBase"
|
name="tautulliUrlBase"
|
||||||
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.tautulliUrlBase &&
|
{errors.tautulliUrlBase &&
|
||||||
@@ -929,7 +939,6 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
|
|||||||
as="field"
|
as="field"
|
||||||
id="tautulliApiKey"
|
id="tautulliApiKey"
|
||||||
name="tautulliApiKey"
|
name="tautulliApiKey"
|
||||||
autoComplete="one-time-code"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.tautulliApiKey &&
|
{errors.tautulliApiKey &&
|
||||||
@@ -950,6 +959,11 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
|
|||||||
inputMode="url"
|
inputMode="url"
|
||||||
id="tautulliExternalUrl"
|
id="tautulliExternalUrl"
|
||||||
name="tautulliExternalUrl"
|
name="tautulliExternalUrl"
|
||||||
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.tautulliExternalUrl &&
|
{errors.tautulliExternalUrl &&
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
|||||||
import Modal from '@app/components/Common/Modal';
|
import Modal from '@app/components/Common/Modal';
|
||||||
import PageTitle from '@app/components/Common/PageTitle';
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
import OverrideRuleModal from '@app/components/Settings/OverrideRule/OverrideRuleModal';
|
import OverrideRuleModal from '@app/components/Settings/OverrideRule/OverrideRuleModal';
|
||||||
|
import OverrideRuleTiles from '@app/components/Settings/OverrideRule/OverrideRuleTiles';
|
||||||
import RadarrModal from '@app/components/Settings/RadarrModal';
|
import RadarrModal from '@app/components/Settings/RadarrModal';
|
||||||
import SonarrModal from '@app/components/Settings/SonarrModal';
|
import SonarrModal from '@app/components/Settings/SonarrModal';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
@@ -14,6 +15,7 @@ import defineMessages from '@app/utils/defineMessages';
|
|||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid';
|
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid';
|
||||||
import type OverrideRule from '@server/entity/OverrideRule';
|
import type OverrideRule from '@server/entity/OverrideRule';
|
||||||
|
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
|
||||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||||
import { Fragment, useState } from 'react';
|
import { Fragment, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
@@ -43,6 +45,10 @@ const messages = defineMessages('components.Settings', {
|
|||||||
mediaTypeMovie: 'movie',
|
mediaTypeMovie: 'movie',
|
||||||
mediaTypeSeries: 'series',
|
mediaTypeSeries: 'series',
|
||||||
deleteServer: 'Delete {serverType} Server',
|
deleteServer: 'Delete {serverType} Server',
|
||||||
|
overrideRules: 'Override Rules',
|
||||||
|
overrideRulesDescription:
|
||||||
|
'Override rules allow you to specify properties that will be replaced if a request matches the rule.',
|
||||||
|
addrule: 'New Override Rule',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface ServerInstanceProps {
|
interface ServerInstanceProps {
|
||||||
@@ -113,6 +119,8 @@ const ServerInstance = ({
|
|||||||
<h3 className="truncate font-medium leading-5 text-white">
|
<h3 className="truncate font-medium leading-5 text-white">
|
||||||
<a
|
<a
|
||||||
href={serviceUrl}
|
href={serviceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
className="transition duration-300 hover:text-white hover:underline"
|
className="transition duration-300 hover:text-white hover:underline"
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
@@ -141,6 +149,8 @@ const ServerInstance = ({
|
|||||||
</span>
|
</span>
|
||||||
<a
|
<a
|
||||||
href={internalUrl}
|
href={internalUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
className="transition duration-300 hover:text-white hover:underline"
|
className="transition duration-300 hover:text-white hover:underline"
|
||||||
>
|
>
|
||||||
{internalUrl}
|
{internalUrl}
|
||||||
@@ -153,7 +163,12 @@ const ServerInstance = ({
|
|||||||
{profileName}
|
{profileName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a href={serviceUrl} className="opacity-50 hover:opacity-100">
|
<a
|
||||||
|
href={serviceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="opacity-50 hover:opacity-100"
|
||||||
|
>
|
||||||
{isSonarr ? (
|
{isSonarr ? (
|
||||||
<SonarrLogo className="h-10 w-10 flex-shrink-0" />
|
<SonarrLogo className="h-10 w-10 flex-shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
@@ -199,6 +214,8 @@ const SettingsServices = () => {
|
|||||||
error: sonarrError,
|
error: sonarrError,
|
||||||
mutate: revalidateSonarr,
|
mutate: revalidateSonarr,
|
||||||
} = useSWR<SonarrSettings[]>('/api/v1/settings/sonarr');
|
} = useSWR<SonarrSettings[]>('/api/v1/settings/sonarr');
|
||||||
|
const { data: rules, mutate: revalidate } =
|
||||||
|
useSWR<OverrideRuleResultsResponse>('/api/v1/overrideRule');
|
||||||
const [editRadarrModal, setEditRadarrModal] = useState<{
|
const [editRadarrModal, setEditRadarrModal] = useState<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
radarr: RadarrSettings | null;
|
radarr: RadarrSettings | null;
|
||||||
@@ -225,11 +242,9 @@ const SettingsServices = () => {
|
|||||||
const [overrideRuleModal, setOverrideRuleModal] = useState<{
|
const [overrideRuleModal, setOverrideRuleModal] = useState<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
rule: OverrideRule | null;
|
rule: OverrideRule | null;
|
||||||
testResponse: DVRTestResponse | null;
|
|
||||||
}>({
|
}>({
|
||||||
open: false,
|
open: false,
|
||||||
rule: null,
|
rule: null,
|
||||||
testResponse: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteServer = async () => {
|
const deleteServer = async () => {
|
||||||
@@ -265,21 +280,6 @@ const SettingsServices = () => {
|
|||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{overrideRuleModal.open && overrideRuleModal.testResponse && (
|
|
||||||
<OverrideRuleModal
|
|
||||||
rule={overrideRuleModal.rule}
|
|
||||||
onClose={() =>
|
|
||||||
setOverrideRuleModal({
|
|
||||||
open: false,
|
|
||||||
rule: null,
|
|
||||||
testResponse: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
testResponse={overrideRuleModal.testResponse}
|
|
||||||
radarrId={editRadarrModal.radarr?.id}
|
|
||||||
sonarrId={editSonarrModal.sonarr?.id}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{editRadarrModal.open && (
|
{editRadarrModal.open && (
|
||||||
<RadarrModal
|
<RadarrModal
|
||||||
radarr={editRadarrModal.radarr}
|
radarr={editRadarrModal.radarr}
|
||||||
@@ -292,8 +292,6 @@ const SettingsServices = () => {
|
|||||||
mutate('/api/v1/settings/public');
|
mutate('/api/v1/settings/public');
|
||||||
setEditRadarrModal({ open: false, radarr: null });
|
setEditRadarrModal({ open: false, radarr: null });
|
||||||
}}
|
}}
|
||||||
overrideRuleModal={overrideRuleModal}
|
|
||||||
setOverrideRuleModal={setOverrideRuleModal}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{editSonarrModal.open && (
|
{editSonarrModal.open && (
|
||||||
@@ -308,8 +306,6 @@ const SettingsServices = () => {
|
|||||||
mutate('/api/v1/settings/public');
|
mutate('/api/v1/settings/public');
|
||||||
setEditSonarrModal({ open: false, sonarr: null });
|
setEditSonarrModal({ open: false, sonarr: null });
|
||||||
}}
|
}}
|
||||||
overrideRuleModal={overrideRuleModal}
|
|
||||||
setOverrideRuleModal={setOverrideRuleModal}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Transition
|
<Transition
|
||||||
@@ -507,6 +503,60 @@ const SettingsServices = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-10 mb-6">
|
||||||
|
<h3 className="heading">
|
||||||
|
{intl.formatMessage(messages.overrideRules)}
|
||||||
|
</h3>
|
||||||
|
<p className="description">
|
||||||
|
{intl.formatMessage(messages.overrideRulesDescription, {
|
||||||
|
serverType: 'Sonarr',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="section">
|
||||||
|
<ul className="grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{rules && radarrData && sonarrData && (
|
||||||
|
<OverrideRuleTiles
|
||||||
|
rules={rules}
|
||||||
|
radarrServices={radarrData}
|
||||||
|
sonarrServices={sonarrData}
|
||||||
|
setOverrideRuleModal={setOverrideRuleModal}
|
||||||
|
revalidate={revalidate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<li className="min-h-[8rem] rounded-lg border-2 border-dashed border-gray-400 shadow sm:min-h-[11rem]">
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<Button
|
||||||
|
buttonType="ghost"
|
||||||
|
disabled={!radarrData?.length && !sonarrData?.length}
|
||||||
|
onClick={() =>
|
||||||
|
setOverrideRuleModal({
|
||||||
|
open: true,
|
||||||
|
rule: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
<span>{intl.formatMessage(messages.addrule)}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{overrideRuleModal.open && radarrData && sonarrData && (
|
||||||
|
<OverrideRuleModal
|
||||||
|
rule={overrideRuleModal.rule}
|
||||||
|
onClose={() => {
|
||||||
|
setOverrideRuleModal({
|
||||||
|
open: false,
|
||||||
|
rule: null,
|
||||||
|
});
|
||||||
|
revalidate();
|
||||||
|
}}
|
||||||
|
radarrServices={radarrData}
|
||||||
|
sonarrServices={sonarrData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
|
import LabeledCheckbox from '@app/components/Common/LabeledCheckbox';
|
||||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||||
import PageTitle from '@app/components/Common/PageTitle';
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
import PermissionEdit from '@app/components/PermissionEdit';
|
import PermissionEdit from '@app/components/PermissionEdit';
|
||||||
@@ -13,6 +14,7 @@ import { Field, Form, Formik } from 'formik';
|
|||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import useSWR, { mutate } from 'swr';
|
import useSWR, { mutate } from 'swr';
|
||||||
|
import * as yup from 'yup';
|
||||||
|
|
||||||
const messages = defineMessages('components.Settings.SettingsUsers', {
|
const messages = defineMessages('components.Settings.SettingsUsers', {
|
||||||
users: 'Users',
|
users: 'Users',
|
||||||
@@ -20,9 +22,15 @@ const messages = defineMessages('components.Settings.SettingsUsers', {
|
|||||||
userSettingsDescription: 'Configure global and default user settings.',
|
userSettingsDescription: 'Configure global and default user settings.',
|
||||||
toastSettingsSuccess: 'User settings saved successfully!',
|
toastSettingsSuccess: 'User settings saved successfully!',
|
||||||
toastSettingsFailure: 'Something went wrong while saving settings.',
|
toastSettingsFailure: 'Something went wrong while saving settings.',
|
||||||
|
loginMethods: 'Login Methods',
|
||||||
|
loginMethodsTip: 'Configure login methods for users.',
|
||||||
localLogin: 'Enable Local Sign-In',
|
localLogin: 'Enable Local Sign-In',
|
||||||
localLoginTip:
|
localLoginTip:
|
||||||
'Allow users to sign in using their email address and password, instead of {mediaServerName} OAuth',
|
'Allow users to sign in using their email address and password',
|
||||||
|
mediaServerLogin: 'Enable {mediaServerName} Sign-In',
|
||||||
|
mediaServerLoginTip:
|
||||||
|
'Allow users to sign in using their {mediaServerName} account',
|
||||||
|
atLeastOneAuth: 'At least one authentication method must be selected.',
|
||||||
newPlexLogin: 'Enable New {mediaServerName} Sign-In',
|
newPlexLogin: 'Enable New {mediaServerName} Sign-In',
|
||||||
newPlexLoginTip:
|
newPlexLoginTip:
|
||||||
'Allow {mediaServerName} users to sign in without first being imported',
|
'Allow {mediaServerName} users to sign in without first being imported',
|
||||||
@@ -42,6 +50,27 @@ const SettingsUsers = () => {
|
|||||||
} = useSWR<MainSettings>('/api/v1/settings/main');
|
} = useSWR<MainSettings>('/api/v1/settings/main');
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
|
|
||||||
|
const schema = yup
|
||||||
|
.object()
|
||||||
|
.shape({
|
||||||
|
localLogin: yup.boolean(),
|
||||||
|
mediaServerLogin: yup.boolean(),
|
||||||
|
})
|
||||||
|
.test({
|
||||||
|
name: 'atLeastOneAuth',
|
||||||
|
test: function (values) {
|
||||||
|
const isValid = ['localLogin', 'mediaServerLogin'].some(
|
||||||
|
(field) => !!values[field]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isValid) return true;
|
||||||
|
return this.createError({
|
||||||
|
path: 'localLogin | mediaServerLogin',
|
||||||
|
message: intl.formatMessage(messages.atLeastOneAuth),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!data && !error) {
|
if (!data && !error) {
|
||||||
return <LoadingSpinner />;
|
return <LoadingSpinner />;
|
||||||
}
|
}
|
||||||
@@ -52,6 +81,8 @@ const SettingsUsers = () => {
|
|||||||
? 'Jellyfin'
|
? 'Jellyfin'
|
||||||
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
||||||
? 'Emby'
|
? 'Emby'
|
||||||
|
: settings.currentSettings.mediaServerType === MediaServerType.PLEX
|
||||||
|
? 'Plex'
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,6 +104,7 @@ const SettingsUsers = () => {
|
|||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
localLogin: data?.localLogin,
|
localLogin: data?.localLogin,
|
||||||
|
mediaServerLogin: data?.mediaServerLogin,
|
||||||
newPlexLogin: data?.newPlexLogin,
|
newPlexLogin: data?.newPlexLogin,
|
||||||
movieQuotaLimit: data?.defaultQuotas.movie.quotaLimit ?? 0,
|
movieQuotaLimit: data?.defaultQuotas.movie.quotaLimit ?? 0,
|
||||||
movieQuotaDays: data?.defaultQuotas.movie.quotaDays ?? 7,
|
movieQuotaDays: data?.defaultQuotas.movie.quotaDays ?? 7,
|
||||||
@@ -80,6 +112,7 @@ const SettingsUsers = () => {
|
|||||||
tvQuotaDays: data?.defaultQuotas.tv.quotaDays ?? 7,
|
tvQuotaDays: data?.defaultQuotas.tv.quotaDays ?? 7,
|
||||||
defaultPermissions: data?.defaultPermissions ?? 0,
|
defaultPermissions: data?.defaultPermissions ?? 0,
|
||||||
}}
|
}}
|
||||||
|
validationSchema={schema}
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
@@ -90,6 +123,7 @@ const SettingsUsers = () => {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
localLogin: values.localLogin,
|
localLogin: values.localLogin,
|
||||||
|
mediaServerLogin: values.mediaServerLogin,
|
||||||
newPlexLogin: values.newPlexLogin,
|
newPlexLogin: values.newPlexLogin,
|
||||||
defaultQuotas: {
|
defaultQuotas: {
|
||||||
movie: {
|
movie: {
|
||||||
@@ -121,30 +155,61 @@ const SettingsUsers = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{({ isSubmitting, values, setFieldValue }) => {
|
{({ isSubmitting, isValid, values, errors, setFieldValue }) => {
|
||||||
return (
|
return (
|
||||||
<Form className="section">
|
<Form className="section">
|
||||||
<div className="form-row">
|
<div
|
||||||
<label htmlFor="localLogin" className="checkbox-label">
|
role="group"
|
||||||
{intl.formatMessage(messages.localLogin)}
|
aria-labelledby="group-label"
|
||||||
<span className="label-tip">
|
className="form-group"
|
||||||
{intl.formatMessage(
|
>
|
||||||
messages.localLoginTip,
|
<div className="form-row">
|
||||||
mediaServerFormatValues
|
<span id="group-label" className="group-label">
|
||||||
|
{intl.formatMessage(messages.loginMethods)}
|
||||||
|
<span className="label-tip">
|
||||||
|
{intl.formatMessage(messages.loginMethodsTip)}
|
||||||
|
</span>
|
||||||
|
{'localLogin | mediaServerLogin' in errors && (
|
||||||
|
<span className="error">
|
||||||
|
{errors['localLogin | mediaServerLogin'] as string}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
|
||||||
<div className="form-input-area">
|
<div className="form-input-area max-w-lg">
|
||||||
<Field
|
<LabeledCheckbox
|
||||||
type="checkbox"
|
id="localLogin"
|
||||||
id="localLogin"
|
label={intl.formatMessage(messages.localLogin)}
|
||||||
name="localLogin"
|
description={intl.formatMessage(
|
||||||
onChange={() => {
|
messages.localLoginTip,
|
||||||
setFieldValue('localLogin', !values.localLogin);
|
mediaServerFormatValues
|
||||||
}}
|
)}
|
||||||
/>
|
onChange={() =>
|
||||||
|
setFieldValue('localLogin', !values.localLogin)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<LabeledCheckbox
|
||||||
|
id="mediaServerLogin"
|
||||||
|
className="mt-4"
|
||||||
|
label={intl.formatMessage(
|
||||||
|
messages.mediaServerLogin,
|
||||||
|
mediaServerFormatValues
|
||||||
|
)}
|
||||||
|
description={intl.formatMessage(
|
||||||
|
messages.mediaServerLoginTip,
|
||||||
|
mediaServerFormatValues
|
||||||
|
)}
|
||||||
|
onChange={() =>
|
||||||
|
setFieldValue(
|
||||||
|
'mediaServerLogin',
|
||||||
|
!values.mediaServerLogin
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="newPlexLogin" className="checkbox-label">
|
<label htmlFor="newPlexLogin" className="checkbox-label">
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
@@ -229,7 +294,7 @@ const SettingsUsers = () => {
|
|||||||
<Button
|
<Button
|
||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || !isValid}
|
||||||
>
|
>
|
||||||
<ArrowDownOnSquareIcon />
|
<ArrowDownOnSquareIcon />
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
import Button from '@app/components/Common/Button';
|
|
||||||
import Modal from '@app/components/Common/Modal';
|
import Modal from '@app/components/Common/Modal';
|
||||||
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||||
import OverrideRuleTile from '@app/components/Settings/OverrideRule/OverrideRuleTile';
|
import type { SonarrTestResponse } from '@app/components/Settings/SettingsServices';
|
||||||
import type {
|
|
||||||
DVRTestResponse,
|
|
||||||
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 { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import { PlusIcon } from '@heroicons/react/24/solid';
|
|
||||||
import type OverrideRule from '@server/entity/OverrideRule';
|
|
||||||
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
|
|
||||||
import type { SonarrSettings } from '@server/lib/settings';
|
import type { SonarrSettings } from '@server/lib/settings';
|
||||||
import { Field, Formik } from 'formik';
|
import { Field, Formik } from 'formik';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
@@ -19,7 +11,6 @@ import { useIntl } from 'react-intl';
|
|||||||
import type { OnChangeValue } from 'react-select';
|
import type { OnChangeValue } from 'react-select';
|
||||||
import Select from 'react-select';
|
import Select from 'react-select';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import useSWR from 'swr';
|
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
type OptionType = {
|
type OptionType = {
|
||||||
@@ -85,36 +76,16 @@ const messages = defineMessages('components.Settings.SonarrModal', {
|
|||||||
animeTags: 'Anime Tags',
|
animeTags: 'Anime Tags',
|
||||||
notagoptions: 'No tags.',
|
notagoptions: 'No tags.',
|
||||||
selecttags: 'Select tags',
|
selecttags: 'Select tags',
|
||||||
overrideRules: 'Override Rules',
|
|
||||||
addrule: 'New Override Rule',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
interface SonarrModalProps {
|
interface SonarrModalProps {
|
||||||
sonarr: SonarrSettings | null;
|
sonarr: SonarrSettings | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
overrideRuleModal: { open: boolean; rule: OverrideRule | null };
|
|
||||||
setOverrideRuleModal: ({
|
|
||||||
open,
|
|
||||||
rule,
|
|
||||||
testResponse,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
rule: OverrideRule | null;
|
|
||||||
testResponse: DVRTestResponse;
|
|
||||||
}) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SonarrModal = ({
|
const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||||
onClose,
|
|
||||||
sonarr,
|
|
||||||
onSave,
|
|
||||||
overrideRuleModal,
|
|
||||||
setOverrideRuleModal,
|
|
||||||
}: SonarrModalProps) => {
|
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { data: rules, mutate: revalidate } =
|
|
||||||
useSWR<OverrideRuleResultsResponse>('/api/v1/overrideRule');
|
|
||||||
const initialLoad = useRef(false);
|
const initialLoad = useRef(false);
|
||||||
const { addToast } = useToasts();
|
const { addToast } = useToasts();
|
||||||
const [isValidated, setIsValidated] = useState(sonarr ? true : false);
|
const [isValidated, setIsValidated] = useState(sonarr ? true : false);
|
||||||
@@ -244,10 +215,6 @@ const SonarrModal = ({
|
|||||||
}
|
}
|
||||||
}, [sonarr, testConnection]);
|
}, [sonarr, testConnection]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
revalidate();
|
|
||||||
}, [overrideRuleModal, revalidate]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition
|
<Transition
|
||||||
as="div"
|
as="div"
|
||||||
@@ -415,7 +382,6 @@ const SonarrModal = ({
|
|||||||
values.is4k ? messages.edit4ksonarr : messages.editsonarr
|
values.is4k ? messages.edit4ksonarr : messages.editsonarr
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
backgroundClickable={!overrideRuleModal.open}
|
|
||||||
>
|
>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
@@ -449,6 +415,11 @@ const SonarrModal = ({
|
|||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
type="text"
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setIsValidated(false);
|
setIsValidated(false);
|
||||||
setFieldValue('name', e.target.value);
|
setFieldValue('name', e.target.value);
|
||||||
@@ -542,7 +513,6 @@ const SonarrModal = ({
|
|||||||
as="field"
|
as="field"
|
||||||
id="apiKey"
|
id="apiKey"
|
||||||
name="apiKey"
|
name="apiKey"
|
||||||
autoComplete="one-time-code"
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setIsValidated(false);
|
setIsValidated(false);
|
||||||
setFieldValue('apiKey', e.target.value);
|
setFieldValue('apiKey', e.target.value);
|
||||||
@@ -1070,42 +1040,6 @@ const SonarrModal = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{sonarr && (
|
|
||||||
<>
|
|
||||||
<h3 className="mb-4 text-xl font-bold leading-8 text-gray-100">
|
|
||||||
{intl.formatMessage(messages.overrideRules)}
|
|
||||||
</h3>
|
|
||||||
<ul className="grid gap-x-4 gap-y-8 sm:grid-cols-3 sm:gap-x-6 sm:gap-y-6 lg:grid-cols-2">
|
|
||||||
{rules && (
|
|
||||||
<OverrideRuleTile
|
|
||||||
rules={rules}
|
|
||||||
setOverrideRuleModal={setOverrideRuleModal}
|
|
||||||
testResponse={testResponse}
|
|
||||||
sonarr={sonarr}
|
|
||||||
revalidate={revalidate}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<li className="min-h-[8rem] rounded-lg border-2 border-dashed border-gray-400 shadow sm:min-h-[11rem]">
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<Button
|
|
||||||
buttonType="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
setOverrideRuleModal({
|
|
||||||
open: true,
|
|
||||||
rule: null,
|
|
||||||
testResponse,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={!isValidated}
|
|
||||||
>
|
|
||||||
<PlusIcon />
|
|
||||||
<span>{intl.formatMessage(messages.addrule)}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
372
src/components/Setup/JellyfinSetup.tsx
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
import Button from '@app/components/Common/Button';
|
||||||
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { InformationCircleIcon } from '@heroicons/react/24/solid';
|
||||||
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
|
import { MediaServerType, ServerType } from '@server/constants/server';
|
||||||
|
import { Field, Form, Formik } from 'formik';
|
||||||
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
import { useToasts } from 'react-toast-notifications';
|
||||||
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
|
const messages = defineMessages('components.Login', {
|
||||||
|
username: 'Username',
|
||||||
|
password: 'Password',
|
||||||
|
hostname: '{mediaServerName} URL',
|
||||||
|
port: 'Port',
|
||||||
|
enablessl: 'Use SSL',
|
||||||
|
urlBase: 'URL Base',
|
||||||
|
email: 'Email Address',
|
||||||
|
emailtooltip:
|
||||||
|
'Address does not need to be associated with your {mediaServerName} instance.',
|
||||||
|
validationhostrequired: '{mediaServerName} URL required',
|
||||||
|
validationhostformat: 'Valid URL required',
|
||||||
|
validationemailrequired: 'You must provide a valid email address',
|
||||||
|
validationemailformat: 'Valid email required',
|
||||||
|
validationusernamerequired: 'Username required',
|
||||||
|
validationpasswordrequired: 'You must provide a password',
|
||||||
|
validationservertyperequired: 'Please select a server type',
|
||||||
|
validationHostnameRequired: 'You must provide a valid hostname or IP address',
|
||||||
|
validationPortRequired: 'You must provide a valid port number',
|
||||||
|
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||||
|
validationUrlBaseLeadingSlash: 'URL base must have a leading slash',
|
||||||
|
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
|
||||||
|
loginerror: 'Something went wrong while trying to sign in.',
|
||||||
|
adminerror: 'You must use an admin account to sign in.',
|
||||||
|
noadminerror: 'No admin user found on the server.',
|
||||||
|
credentialerror: 'The username or password is incorrect.',
|
||||||
|
invalidurlerror: 'Unable to connect to {mediaServerName} server.',
|
||||||
|
signingin: 'Signing In…',
|
||||||
|
signin: 'Sign In',
|
||||||
|
initialsigningin: 'Connecting…',
|
||||||
|
initialsignin: 'Connect',
|
||||||
|
forgotpassword: 'Forgot Password?',
|
||||||
|
servertype: 'Server Type',
|
||||||
|
back: 'Go back',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface JellyfinSetupProps {
|
||||||
|
revalidate: () => void;
|
||||||
|
serverType?: MediaServerType;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function JellyfinSetup({
|
||||||
|
revalidate,
|
||||||
|
serverType,
|
||||||
|
onCancel,
|
||||||
|
}: JellyfinSetupProps) {
|
||||||
|
const toasts = useToasts();
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const mediaServerFormatValues = {
|
||||||
|
mediaServerName:
|
||||||
|
serverType === MediaServerType.JELLYFIN
|
||||||
|
? ServerType.JELLYFIN
|
||||||
|
: serverType === MediaServerType.EMBY
|
||||||
|
? ServerType.EMBY
|
||||||
|
: 'Media Server',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoginSchema = Yup.object().shape({
|
||||||
|
hostname: Yup.string().required(
|
||||||
|
intl.formatMessage(
|
||||||
|
messages.validationhostrequired,
|
||||||
|
mediaServerFormatValues
|
||||||
|
)
|
||||||
|
),
|
||||||
|
port: Yup.number().required(
|
||||||
|
intl.formatMessage(messages.validationPortRequired)
|
||||||
|
),
|
||||||
|
urlBase: Yup.string()
|
||||||
|
.test(
|
||||||
|
'leading-slash',
|
||||||
|
intl.formatMessage(messages.validationUrlBaseLeadingSlash),
|
||||||
|
(value) => !value || value.startsWith('/')
|
||||||
|
)
|
||||||
|
.test(
|
||||||
|
'trailing-slash',
|
||||||
|
intl.formatMessage(messages.validationUrlBaseTrailingSlash),
|
||||||
|
(value) => !value || !value.endsWith('/')
|
||||||
|
),
|
||||||
|
email: Yup.string()
|
||||||
|
.email(intl.formatMessage(messages.validationemailformat))
|
||||||
|
.required(intl.formatMessage(messages.validationemailrequired)),
|
||||||
|
username: Yup.string().required(
|
||||||
|
intl.formatMessage(messages.validationusernamerequired)
|
||||||
|
),
|
||||||
|
password: Yup.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
hostname: '',
|
||||||
|
port: 8096,
|
||||||
|
useSsl: false,
|
||||||
|
urlBase: '',
|
||||||
|
email: '',
|
||||||
|
}}
|
||||||
|
validationSchema={LoginSchema}
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
try {
|
||||||
|
// Check if serverType is either 'Jellyfin' or 'Emby'
|
||||||
|
// if (serverType !== 'Jellyfin' && serverType !== 'Emby') {
|
||||||
|
// throw new Error('Invalid serverType'); // You can customize the error message
|
||||||
|
// }
|
||||||
|
|
||||||
|
const res = await fetch('/api/v1/auth/jellyfin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: values.username,
|
||||||
|
password: values.password,
|
||||||
|
hostname: values.hostname,
|
||||||
|
port: values.port,
|
||||||
|
useSsl: values.useSsl,
|
||||||
|
urlBase: values.urlBase,
|
||||||
|
email: values.email,
|
||||||
|
serverType: serverType,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
||||||
|
} catch (e) {
|
||||||
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
|
let errorMessage = null;
|
||||||
|
switch (errorData?.message) {
|
||||||
|
case ApiErrorCode.InvalidUrl:
|
||||||
|
errorMessage = messages.invalidurlerror;
|
||||||
|
break;
|
||||||
|
case ApiErrorCode.InvalidCredentials:
|
||||||
|
errorMessage = messages.credentialerror;
|
||||||
|
break;
|
||||||
|
case ApiErrorCode.NotAdmin:
|
||||||
|
errorMessage = messages.adminerror;
|
||||||
|
break;
|
||||||
|
case ApiErrorCode.NoAdminUser:
|
||||||
|
errorMessage = messages.noadminerror;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
errorMessage = messages.loginerror;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
toasts.addToast(
|
||||||
|
intl.formatMessage(errorMessage, mediaServerFormatValues),
|
||||||
|
{
|
||||||
|
autoDismiss: true,
|
||||||
|
appearance: 'error',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
revalidate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ errors, touched, values, setFieldValue, isSubmitting, isValid }) => (
|
||||||
|
<Form>
|
||||||
|
<div className="sm:border-t sm:border-gray-800">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:gap-4">
|
||||||
|
<div className="w-full">
|
||||||
|
<label htmlFor="hostname" className="text-label">
|
||||||
|
{intl.formatMessage(
|
||||||
|
messages.hostname,
|
||||||
|
mediaServerFormatValues
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 mb-2 sm:col-span-2 sm:mb-0 sm:mt-0">
|
||||||
|
<div className="flex rounded-md shadow-sm">
|
||||||
|
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||||
|
{values.useSsl ? 'https://' : 'http://'}
|
||||||
|
</span>
|
||||||
|
<Field
|
||||||
|
id="hostname"
|
||||||
|
name="hostname"
|
||||||
|
type="text"
|
||||||
|
className="rounded-r-only flex-1"
|
||||||
|
placeholder={intl.formatMessage(
|
||||||
|
messages.hostname,
|
||||||
|
mediaServerFormatValues
|
||||||
|
)}
|
||||||
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.hostname && touched.hostname && (
|
||||||
|
<div className="error">{errors.hostname}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label htmlFor="port" className="text-label">
|
||||||
|
{intl.formatMessage(messages.port)}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 sm:mt-0">
|
||||||
|
<Field
|
||||||
|
id="port"
|
||||||
|
name="port"
|
||||||
|
inputMode="numeric"
|
||||||
|
type="text"
|
||||||
|
className="short flex-1"
|
||||||
|
placeholder={intl.formatMessage(messages.port)}
|
||||||
|
/>
|
||||||
|
{errors.port && touched.port && (
|
||||||
|
<div className="error">{errors.port}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label htmlFor="useSsl" className="text-label mt-2">
|
||||||
|
{intl.formatMessage(messages.enablessl)}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 mb-2 sm:col-span-2">
|
||||||
|
<div className="flex rounded-md shadow-sm">
|
||||||
|
<Field
|
||||||
|
id="useSsl"
|
||||||
|
name="useSsl"
|
||||||
|
type="checkbox"
|
||||||
|
onChange={() => {
|
||||||
|
setFieldValue('useSsl', !values.useSsl);
|
||||||
|
setFieldValue('port', values.useSsl ? 8096 : 443);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label htmlFor="urlBase" className="text-label mt-1">
|
||||||
|
{intl.formatMessage(messages.urlBase)}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||||
|
<div className="flex rounded-md shadow-sm">
|
||||||
|
<Field
|
||||||
|
type="text"
|
||||||
|
inputMode="url"
|
||||||
|
id="urlBase"
|
||||||
|
name="urlBase"
|
||||||
|
placeholder={intl.formatMessage(messages.urlBase)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.urlBase && touched.urlBase && (
|
||||||
|
<div className="error">{errors.urlBase}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="text-label inline-flex gap-1 align-middle"
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.email)}
|
||||||
|
<span className="label-tip">
|
||||||
|
<Tooltip
|
||||||
|
content={intl.formatMessage(
|
||||||
|
messages.emailtooltip,
|
||||||
|
mediaServerFormatValues
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="tooltip-trigger">
|
||||||
|
<InformationCircleIcon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 sm:col-span-2 sm:mb-2 sm:mt-0">
|
||||||
|
<div className="flex rounded-md shadow-sm">
|
||||||
|
<Field
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="text"
|
||||||
|
placeholder={intl.formatMessage(messages.email)}
|
||||||
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.email && touched.email && (
|
||||||
|
<div className="error">{errors.email}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label htmlFor="username" className="text-label">
|
||||||
|
{intl.formatMessage(messages.username)}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||||
|
<div className="flex rounded-md shadow-sm">
|
||||||
|
<Field
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
placeholder={intl.formatMessage(messages.username)}
|
||||||
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.username && touched.username && (
|
||||||
|
<div className="error">{errors.username}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label htmlFor="password" className="text-label">
|
||||||
|
{intl.formatMessage(messages.password)}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||||
|
<div className="flexrounded-md shadow-sm">
|
||||||
|
<Field
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
placeholder={intl.formatMessage(messages.password)}
|
||||||
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.password && touched.password && (
|
||||||
|
<div className="error">{errors.password}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 border-t border-gray-700 pt-5">
|
||||||
|
<div className="flex flex-row-reverse justify-between">
|
||||||
|
<span className="inline-flex rounded-md shadow-sm">
|
||||||
|
<Button
|
||||||
|
buttonType="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || !isValid}
|
||||||
|
>
|
||||||
|
{isSubmitting
|
||||||
|
? intl.formatMessage(messages.signingin)
|
||||||
|
: intl.formatMessage(messages.signin)}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
{onCancel && (
|
||||||
|
<span className="inline-flex rounded-md shadow-sm">
|
||||||
|
<Button buttonType="default" onClick={() => onCancel()}>
|
||||||
|
<FormattedMessage {...messages.back} />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default JellyfinSetup;
|
||||||